Skip to content

fix(gateway): deny sibling-profile credentials in media delivery - #70144

Open
fangliquanflq wants to merge 7 commits into
NousResearch:mainfrom
fangliquanflq:fix/media-delivery-sibling-profile-credentials
Open

fix(gateway): deny sibling-profile credentials in media delivery#70144
fangliquanflq wants to merge 7 commits into
NousResearch:mainfrom
fangliquanflq:fix/media-delivery-sibling-profile-credentials

Conversation

@fangliquanflq

@fangliquanflq fangliquanflq commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Default (non-strict) media delivery only denied credential stores under the active HERMES_HOME and the shared Hermes root. Sibling profile paths under <root>/profiles/<other>/ were accepted, so a prompt-injected MEDIA: tag could attach another profile's secrets as a chat document. This applies the full current credential file/dir policy to every live profile directory (same enumeration pattern as cache allowlisting).

Bug Cause

_media_delivery_denied_paths() in gateway/platforms/base.py only appended _ROOT_CREDENTIAL_FILES / _ROOT_CREDENTIAL_DIRS for (_HERMES_HOME, _HERMES_ROOT). In non-strict mode, validate_media_delivery_path accepts any existing regular file not on that denylist, so MEDIA:<root>/profiles/bob/.env (and auth/OAuth/mcp-tokens/pairing/etc.) delivered while alice/.env and root .env stayed blocked.

Reproduction Steps

  1. Create a temp Hermes root with profiles/alice and profiles/bob; write credential files under bob (.env, auth.json, .anthropic_oauth.json, mcp-tokens/, pairing/, google_token.json, webhook_subscriptions.json, credentials/, cache/bws_cache.json).
  2. Patch _HERMES_HOME to alice and _HERMES_ROOT to the temp root; leave HERMES_MEDIA_DELIVERY_STRICT unset/off.
  3. Call validate_media_delivery_path on each bob credential path and on alice/root .env.

Expected: sibling credential paths return None (same as active/root).
Before fix: bob credential paths returned an allowed absolute path; alice/root .env correctly returned None.

Fix

  • Extract _iter_hermes_profile_dirs() and reuse it from _profile_cache_roots() and _media_delivery_denied_paths().
  • Resolve each home/root/profile path before dedup (fail closed to the unresolved path on resolve error), then apply the full credential file/dir list to every profile.
  • Tests: parametrized sibling credential denial, home-is-root sibling case, and a non-credential sibling notes.md still delivers.

Supersedes #47220 (that PR only denied four control files and lagged the expanded credential set on current main).

Related Issue

No issue

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • Security fix

Changes Made

  • gateway/platforms/base.py - enumerate sibling profiles into the media-delivery credential denylist; share profile-dir iteration with cache allowlisting
  • tests/gateway/test_platform_base.py - non-strict sibling credential / home-is-root / non-credential control tests

How to Test

  1. Manual: with HERMES_HOME=<root>/profiles/alice, assert validate_media_delivery_path(<root>/profiles/bob/.env) (and other credential rels) is None, while <root>/profiles/bob/notes.md still resolves.
  2. Automated (already run locally, 211 passed):
scripts/run_tests.sh tests/gateway/test_platform_base.py -q

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate (supersedes fix(gateway): deny sibling-profile credential paths in media delivery #47220)
  • My PR contains only changes related to this fix
  • I've run scripts/run_tests.sh on relevant tests and they pass
  • I've added tests for my changes
  • I've tested on my platform: Windows 11

Documentation & Housekeeping

  • I've updated relevant documentation - N/A
  • I've updated cli-config.yaml.example if I added/changed config keys - N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows - N/A
  • I've considered cross-platform impact - path resolve + profiles/*/ enumeration
  • I've updated tool descriptions/schemas if I changed tool behavior - N/A

@alt-glitch alt-glitch added type/security Security vulnerability or hardening P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery area/auth Authentication, OAuth, credential pools sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Jul 23, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: #47220 addresses the same sibling-profile media-delivery gap with a narrower credential list. This PR applies the full current credential policy and adds broader regression coverage.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused security fix. The premise is confirmed on current main: gateway/platforms/base.py:1382 applies the credential denylist only to _HERMES_HOME and _HERMES_ROOT, while the default-mode branch at gateway/platforms/base.py:1511-1514 accepts any existing file not covered by that denylist. This leaves sibling-profile credential files outside the active two-root policy.

The proposed reuse of the live profile-directory enumeration already used by _profile_cache_roots() (gateway/platforms/base.py:1247-1254) is a narrow fit for the existing media-delivery design. The PR diff also covers the active-profile, root-home, and non-credential sibling-profile cases.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform area/profiles Multi-profile isolation, HERMES_HOME scoping labels Jul 30, 2026
@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The PR closes the normal sibling-profile credential leak, but its new security decision depends on successfully listing the profiles directory. The helper explicitly converts every listing OSError into an empty profile set. On a POSIX profiles directory with execute permission but no read permission, a known sibling credential remains readable by path while enumeration fails, and PR-head validation accepts that credential for media delivery. Because this is a fail-open residual bypass at the exact boundary the PR intends to close, the change is not yet mergeable.

  • [P2] Fail closed when sibling profiles cannot be enumerated (gateway/platforms/base.py:1069)
    At line 1069, _iter_hermes_profile_dirs() catches every OSError from profiles_dir.iterdir() and returns an empty list. _media_delivery_denied_paths() consequently installs no sibling-profile credential paths, while default delivery mode accepts any existing regular file not otherwise denied. This is exploitable without making the credential unreadable: on POSIX, a profiles/ directory can be execute-only (0111), so a caller that knows bob/.env can open it even though it cannot list profile names. A local PR-head probe created exactly that layout; _iter_hermes_profile_dirs() returned [], but validate_media_delivery_path(.../profiles/bob/.env) returned the absolute path instead of None. A model-controlled MEDIA: path can therefore still attach the sibling credential under the error condition this code explicitly handles.
    Remediation: Do not derive the deny decision from directory enumeration. Resolve the candidate relative to <root>/profiles and, for any immediate profile child, apply the credential file/directory policy directly from its remaining path components. Alternatively fail closed for candidates under profiles/ whenever enumeration errors. Add a regression case that makes profiles/ execute-only (or forces iterdir() to raise) while keeping a known sibling .env readable, and assert delivery is denied while a non-credential sibling file remains allowed under the chosen policy.

Security evidence:

  • trust boundary: The untrusted source is a model-emitted local attachment path (for example a MEDIA: directive) handled by gateway media delivery. The sink is native attachment delivery to a gateway chat, which reads and transmits the resolved local file. The sensitive boundary separates an active/default Hermes home from credential stores in inactive sibling homes under <root>/profiles/<name>. Filesystem metadata and profile-directory enumeration can fail independently of opening a known child path and therefore cannot be treated as trusted proof that no sibling exists.
  • source/sink/invariant: After path expansion, symlink resolution, existence and regular-file validation, any candidate matching a Hermes credential filename or credential directory in the active home, shared root, or any sibling profile must be rejected before default-mode, recency, or cache delivery can accept it. The PR claims live sibling coverage by sharing _iter_hermes_profile_dirs() between cache allowlisting and credential denial, but the invariant is conditional on successful enumeration because OSError produces an empty set.
  • current-main reproduction: I loaded gateway/platforms/base.py directly from bound current-main object 991f5f1 with git show, executed that exact source in an isolated module, configured an active alice profile and sibling bob/.env, and used default delivery mode with no static safe roots. validate_media_delivery_path() returned the sibling .env absolute path, reproducing the original leak on current main.
  • PR-head or patch-replay validation: The leased checkout is exactly PR head 9552b9551ed96433625d7f74f5a8ddd7b9319887. A three-way git merge-tree using merge base 683059f, bound current main, and PR head showed both touched files merging without conflict markers. On PR head, a normal readable sibling profile caused .env to return None and notes.md to remain deliverable. A second PR-head probe changed only the profiles directory to mode 0111: enumeration returned [] and the same readable bob/.env was accepted, validating the residual bypass.
  • positive/negative cases: Positive security case: readable <root>/profiles/bob/.env was denied on PR head. Compatibility case: readable <root>/profiles/bob/notes.md was returned as a deliverable resolved path, preserving targeted rather than whole-profile denial. Negative/error case: with <root>/profiles execute-only and the known .env still openable, PR head returned the secret path. The PR's parameterized tests cover the named credential set and the normal directory state, but not the helper's explicit OSError branch.
  • residual bypass search: I traced allowed roots, deny construction, symlink resolution, prefix containment, recency/default fallthrough, MEDIA: call sites, and profile-name validation references. Resolving enumerated profile directories handles ordinary symlinks, and the explicit credential files/directories cover the tested sibling stores. The remaining source-backed bypass is enumeration failure: because denial is built from discovered siblings rather than from the candidate's structural location under profiles/, a known readable child can evade the new list. No additional source-backed bypass was validated.
  • reviewer validation: I independently reviewed the full PR diff against its merge base and current main, checked whitespace with git diff --check, inspected line-numbered source and downstream media-path uses, and ran focused Python probes against exact current-main source and the leased PR head. python -B -m compileall gateway/platforms/base.py succeeded. The repository test runner could not execute because the leased checkout has no .venv/venv and HERMES_PYTHON did not resolve to a Python with pytest; direct pytest was absent and /usr/bin/python -m pytest reported no pytest module. The finding does not depend on those unavailable tests because the security behavior was reproduced directly through the production validator.

Uncertainty: The frequency of execute-only or transiently unreadable profile directories in supported deployments is unknown; it affects exploit prevalence, not the demonstrated fail-open behavior.; The full focused pytest file and full suite were not runnable in the leased checkout because no pytest-capable interpreter was available.; The clean merge-tree result establishes coherent source integration onto current main, but the merged tree was not materialized or test-executed because checkout and Git metadata mutations were prohibited.

Signed: GPT-5.6-sol-xhigh in Codex

@fangliquanflq

Copy link
Copy Markdown
Contributor Author

Addressed the P2 fail-open on sibling-profile credential denial.

_media_delivery_denied_paths() no longer derives sibling denials from _iter_hermes_profile_dirs(). Credential policy under <root>/profiles/<name>/ is applied structurally via _path_is_profile_tree_credential() (path components only), so a known sibling .env stays denied when profiles/ listing raises OSError (e.g. execute-only 0111). Non-credential sibling files such as notes.md remain deliverable.

Also fail-closed if profiles_root.resolve() itself errors (fall back to the unresolved path instead of skipping denial).

Regression: test_sibling_credential_denied_when_profiles_enumeration_fails forces profiles/ iterdir() to raise and asserts .env denied + notes.md allowed while _iter_hermes_profile_dirs() returns [].

Commit: 94753dcf7

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The change denies credential-relative paths for ordinary sibling profiles even when profile enumeration is unavailable, while preserving delivery of sibling non-credential files. However, the new structural check receives the resolved target. Hermes profile discovery accepts directory symlinks, so a credential beneath a symlinked sibling resolves outside the structural profiles root and remains deliverable in default mode.

  • [P2] Symlinked sibling profiles bypass the credential denial (gateway/platforms/base.py:1145)
    validate_media_delivery_path() resolves the candidate before _path_under_denied_prefix() calls _path_is_profile_tree_credential(). That helper recognizes a sibling credential only when the resolved target remains below the resolved <root>/profiles directory. For a symlinked sibling profile, the target is outside that directory; the structural match fails, the active-home and shared-root credential entries do not cover the external target, and default delivery accepts it. Preserve the enumeration-independent structural check, but also apply the credential-relative policy to the resolved homes of profile entries accepted by discovery, or consistently reject directory symlinks at every profile entry point. Add a regression case proving that credential files and credential directories beneath a symlinked sibling are denied while its ordinary file behavior remains unchanged.

Security evidence:

  • trust boundary: Model-emitted MEDIA values cross from untrusted conversation content into native gateway attachments, with validate_media_delivery_path() as the final local-file boundary.
  • source/sink/invariant: The source is an absolute file value parsed from model output, the sink is native attachment delivery after validation returns a canonical file, and the required invariant is that credential-relative files for every active, shared, or named profile are rejected without denying ordinary sibling files.
  • current-main reproduction: Current main accepts an ordinary sibling profile credential in default mode, confirming the disclosure premise addressed by this PR.
  • PR-head or patch-replay validation: The PR head denies ordinary sibling credentials and preserves ordinary sibling-file delivery, but still accepts a credential reached through a symlinked sibling profile; the security delta also applies coherently to current main.
  • positive/negative cases: Checked coverage denied the credential-file and credential-directory matrix, a file symlink to a sibling credential, and the ordinary sibling credential when enumeration was unavailable, while allowing the intended sibling non-credential file; the directory-symlink credential remained accepted.
  • residual bypass search: Candidate normalization, canonical resolution, strict and default branches, allowed-root precedence, active/shared-root denial, profile discovery, unavailable enumeration, and file and directory symlinks leave the directory-symlink case as the remaining bypass.
  • reviewer validation: Source tracing confirms that profile discovery follows directory symlinks, candidate canonicalization precedes the structural profile match, and default mode accepts a regular file when the deny check does not recognize its resolved target.

Not checked:

  • Focused pytest suite
  • Full test suite
  • CodeRabbit review

Signed: GPT-5.6-sol-xhigh in Codex

@fangliquanflq

Copy link
Copy Markdown
Contributor Author

Addressed the directory-symlink sibling bypass.

_media_delivery_denied_paths() now also applies the credential file/dir policy to each resolved home from _iter_hermes_profile_dirs(), while keeping the enumeration-independent structural check for unreadable profiles/.

Regression: test_symlinked_sibling_profile_credentials_denied covers credential file + credential-dir under a symlinked sibling (denied) and ordinary notes.md (still delivered).

Verified: scripts/run_tests.sh tests/gateway/test_platform_base.py -q -k "sibling or symlinked_sibling" (18 passed).

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The change closes the ordinary sibling-profile credential exposure and adds useful fail-closed and symlink coverage, but the two exceptional conditions can still be combined to bypass the denial. If the profile registry cannot be enumerated and the sibling profile is reached through a directory symlink, canonicalization removes the profile-tree ancestry before either denial layer can recognize it. One source-backed P2 finding remains.

  • [P2] Preserve profile ancestry when symlink resolution and profile enumeration both obscure it (gateway/platforms/base.py:1310)
    The candidate is resolved before _path_under_denied_prefix runs, so _path_is_profile_tree_credential receives only the symlink target. The fallback for symlinked profile homes depends on _iter_hermes_profile_dirs, which returns an empty list when the profile registry cannot be listed. Consequently, a traversable sibling-profile symlink can still expose its .env or credential directory in default delivery mode when enumeration fails. The new tests cover an unreadable registry and a symlinked sibling separately, but not their conjunction.
    Remediation: Retain a normalized lexical form of the submitted absolute path in addition to the resolved target, and apply the sibling-profile credential-relative policy to that lexical ancestry without depending on directory enumeration. Add a regression case combining a symlinked sibling profile with failed profile enumeration, covering both a root credential file and a credential directory while preserving delivery of a non-credential sibling file.

Security evidence:

  • trust boundary: The source is a model-emitted local MEDIA path accepted by gateway reply processing; the sink is native attachment upload to a messaging user. Hermes profile homes contain API credentials, OAuth tokens, pairing state, and configuration secrets, so paths crossing from an inactive sibling profile into attachment delivery are security-sensitive.
  • source/sink/invariant: After canonicalization, every active, shared-root, or sibling-profile path whose profile-relative suffix is a credential file or credential directory must be rejected before attachment delivery, while ordinary sibling-profile documents remain deliverable under the default-mode product contract.
  • current-main reproduction: An isolated execution of the bound current-main module confirmed that default-mode validation accepts a regular file at a sibling profile's credential-relative location, establishing the reported source-to-sink behavior on current main.
  • PR-head or patch-replay validation: The bound PR head rejects ordinary sibling credentials even when profile enumeration fails and rejects symlinked sibling credentials when enumeration succeeds. It still accepts the resolved credential when a sibling directory symlink and failed profile enumeration occur together.
  • positive/negative cases: Independent PR-head probing confirmed three cases: an ordinary sibling credential is rejected with enumeration unavailable, an ordinary non-credential file through the sibling symlink remains accepted, and a credential through the same symlink is accepted when enumeration is unavailable.
  • residual bypass search: The residual search composed the two exceptional paths introduced by the final two commits: loss of structural ancestry through symlink resolution and loss of discovered roots through failed enumeration. Their conjunction bypasses both layers because neither retains the original profile-relative provenance.
  • reviewer validation: Reviewer inspection traced the path from absolute input parsing through strict resolution, cache handling, deny-prefix evaluation, and attachment filtering. Independent probes exercised current main and the bound PR head, and the modified production module compiled successfully.

Not checked:

  • Focused test suite
  • Full test suite
  • CodeRabbit review

Signed: GPT-5.6-sol-xhigh in Codex

@fangliquanflq

Copy link
Copy Markdown
Contributor Author

Addressed the residual P2 (symlink sibling + failed profiles/ enumeration).

Fix: keep a normalized lexical form of the submitted absolute path in validate_media_delivery_path, and apply sibling-profile credential policy via _path_is_lexical_profile_tree_credential before relying on resolve() / _iter_hermes_profile_dirs.

Regression: test_symlinked_sibling_credential_denied_when_enumeration_fails covers root credential (.env), credential dir (mcp-tokens/), and non-credential sibling (notes.md) under that conjunction.

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

Case-insensitive POSIX filesystems still permit a sibling-profile credential to cross the media-delivery boundary through mixed-case path spelling. _rel_matches_hermes_root_credential and both profile-tree helpers compare PosixPath components case-sensitively, while Path.resolve() preserves the supplied spelling of non-symlink components. On a default case-insensitive macOS volume, a model-emitted path such as <root>/PROFILES/bob/.ENV can therefore open the real <root>/profiles/bob/.env but miss the lexical, structural, and discovered-home deny checks; default mode then returns it for attachment delivery. Normalize profile ancestry and credential-relative comparisons for case-insensitive filesystem semantics, or fail closed with casefolded comparisons, and add mixed-case regression coverage including the failed-profile-enumeration path.

Security evidence:

  • trust boundary: The untrusted source is a model-emitted absolute MEDIA path, and validate_media_delivery_path is the boundary before a gateway adapter turns it into a native outbound attachment. Sibling profile homes share process permissions but their credential stores must remain isolated.
  • source/sink/invariant: A credential-relative path under <root>/profiles/<name> must be denied before non-strict mode returns the resolved regular file. That invariant currently depends on case-sensitive PosixPath.relative_to and equality checks even when the filesystem resolves names case-insensitively.
  • current-main reproduction: The bound current-main comparison reproduced the original exact-case route: with alice active, profiles/bob/.env was accepted for delivery.
  • PR-head or patch-replay validation: The reviewed head denied the exact-case sibling .env and the 14-entry credential matrix while preserving ordinary notes.md delivery, but its new helper and both profile ancestry checks still compare mixed-case aliases case-sensitively.
  • positive/negative cases: Exact-case credential files, credential-directory descendants, unreadable profile enumeration, directory-symlink profiles, and normalized traversal were covered; ordinary sibling files remained deliverable. Mixed-case profile and credential components were not covered.
  • residual bypass search: On case-insensitive POSIX filesystems, Path.resolve() preserves non-symlink input spelling, so mixed-case aliases can miss the structural and lexical checks as well as the discovered-home deny paths and reach the default-mode return.
  • reviewer validation: Source review confirmed the comparison behavior in _rel_matches_hermes_root_credential, _path_is_profile_tree_credential, _path_is_lexical_profile_tree_credential, _media_delivery_denied_paths, and both _path_under_denied_prefix call sites; the added tests use lowercase profile and credential components.

Not checked:

  • Focused pytest suite
  • Full test suite
  • Case-insensitive filesystem runtime probe

Signed: GPT-5.6-sol-xhigh in Codex

fangliquanflq and others added 5 commits August 2, 2026 15:48
…g fails

Deny credentials under profiles/<name>/ by path structure so media delivery cannot accept a known sibling secret when profiles/ iterdir raises OSError.
Apply the credential-relative denylist to resolved homes from profile
discovery so directory-symlink siblings cannot bypass the structural
profiles-tree check after path canonicalization.
Retain lexical profile ancestry so resolve()+failed profiles/ enumeration
cannot jointly bypass credential denial in media delivery.
Mixed-case aliases on case-insensitive filesystems could miss profile-tree and credential-relative deny checks while still opening the real secret.
@fangliquanflq
fangliquanflq force-pushed the fix/media-delivery-sibling-profile-credentials branch from 560889f to 83e8f90 Compare August 2, 2026 07:56
@fangliquanflq

Copy link
Copy Markdown
Contributor Author

Addressed the case-insensitive filesystem bypass.

Fix: deny-side path matching now uses casefolded comparisons for credential-relative checks (_rel_matches_hermes_root_credential), structural/lexical profile-tree ancestry, and _path_under_denied_prefix denylist containment. Mixed-case aliases such as <root>/PROFILES/bob/.ENV are denied even when Path.resolve() preserves the supplied spelling.

Regression: test_mixed_case_sibling_credential_helpers, test_mixed_case_sibling_credential_denied, and test_mixed_case_sibling_credential_denied_when_enumeration_fails cover helper matching, end-to-end delivery denial, and the failed profiles/ enumeration path, while ordinary sibling notes.md remains deliverable.

Verified: pytest tests/gateway/test_platform_base.py -q -k "sibling or mixed_case_sibling" (22 passed).

Commit: 83e8f90

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The sibling-profile credential denial closes the reported exfiltration path for ordinary, unreadable-parent, symlinked-profile, and mixed-case inputs while preserving delivery of non-credential files. However, the new unconditional casefold comparison also changes the running-home exception: on a case-sensitive filesystem it can treat two distinct directories as the same home and exempt an actual hard-denied system tree. This regression can make a model-controlled media path eligible for native attachment, so the case-insensitive comparison needs to preserve filesystem identity before merge.

  • [P2] P2: Preserve filesystem identity in the running-home exception (gateway/platforms/base.py:1594)
    At the running-home exception, _paths_equal_casefold(resolved_denied, home) treats paths whose components differ only by case as identical even on a case-sensitive filesystem. If the configured home has the same casefolded spelling as a hard-denied system root but is actually a distinct directory, a candidate under the real denied root first matches the deny entry and is then incorrectly exempted. validate_media_delivery_path consequently accepts the file in default mode, allowing model-controlled media output to attach data from a tree the hard denylist is intended to protect. The previous exact resolved-path equality denied this case.
    Remediation: Make the home exception depend on actual filesystem identity, such as exact resolved equality or a guarded same-file check. Apply casefold equivalence only when the filesystem has been established as case-insensitive. Add a case-sensitive regression test using two existing directories whose names differ only by case and assert that a file under the configured denied root remains rejected.

Security evidence:

  • trust boundary: A model-controlled MEDIA tag or detected local-file path crosses from response text into a gateway-native attachment. validate_media_delivery_path is the validator between that source and filesystem files containing profile credentials or host secrets.
  • source/sink/invariant: A candidate resolving within a credential or hard-denied system tree must return no deliverable path regardless of symlinks, profile enumeration, or path casing. A regular non-credential sibling-profile file should remain deliverable in default mode.
  • current-main reproduction: A focused replay of the bound main implementation accepted a sibling profile environment credential while accepting the sibling non-credential control, confirming the reported pre-patch gap. The bound main home exception continued to reject a distinct hard-denied tree whose spelling only casefolded to the configured home.
  • PR-head or patch-replay validation: The PR-head validator rejected the sibling environment credential and accepted the non-credential control. A second case-sensitive probe showed that PR head accepted a file beneath a configured hard-denied tree when a distinct configured home differed only by case; replaying the bound-main comparison rejected it.
  • positive/negative cases: Positive controls covered delivery of an ordinary sibling-profile notes file. Negative cases covered a sibling environment credential and a hard-denied-tree file. The sibling fix passed, but the hard-denied-tree negative case became deliverable under the new home comparison.
  • residual bypass search: The review traced lexical and resolved sibling-profile checks, failed profile enumeration, directory symlink targets, credential file and directory matching, mixed-case matching, cache precedence, strict and default deny call sites, and the running-home exception. The identity loss in the home exception was the remaining validated bypass.
  • reviewer validation: The reviewer independently compared the bound current-main blob with PR head, traced every call to the changed deny helper, exercised the public validator with source and control files, reproduced the case-sensitive identity collision, and compiled both changed Python files successfully.

Not checked:

  • Focused pytest suite
  • Full test suite
  • CodeRabbit review
  • Cross-platform filesystem matrix

Signed: GPT-5.6-sol-xhigh in Codex

@alt-glitch alt-glitch added needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have and removed P2 Medium — degraded but workaround exists labels Aug 2, 2026
@alt-glitch alt-glitch removed the needs-decision Awaiting maintainer decision before any implementation label Aug 2, 2026
Unconditional casefold equality incorrectly exempted a hard-denied system
tree when a distinct configured home only casefolded to the same spelling.
Require exact equality or Path.samefile, and fix mixed-case tests for
case-sensitive CI volumes.
@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The PR closes the ordinary sibling-profile credential path, including mixed-case, unreadable-directory, and directory-symlink forms, while preserving delivery of ordinary sibling files. One deterministic, source-backed bypass remains: credential policy runs only after unconditional safe-root acceptance, so a sibling credential path that resolves into a profile cache or operator-allowed root is returned for delivery. The credential check must precede safe-root acceptance for lexically or structurally credential-shaped paths.

  • [P3] P3: Check sibling credential identity before accepting safe roots (gateway/platforms/base.py:1701)
    The new lexical and structural sibling-profile credential checks are reached only after _media_delivery_allowed_roots() has accepted the resolved target. A sibling credential such as <root>/profiles/bob/.env can therefore be delivered when that file is a symlink whose target is under bob/cache/images (or another unconditional/operator-allowed root): resolution produces the cache target, the loop returns it immediately, and _path_under_denied_prefix(..., lexical=...) never evaluates the submitted .env ancestry. This violates the PR's invariant that sibling-profile credential files are never native attachments, even though ordinary cache artifacts must remain deliverable.
    Remediation: Evaluate the lexical and resolved sibling-profile credential predicates before the safe-root return, then retain the existing allowlist behavior for paths that are not credential-shaped. Add a regression case with profiles/bob/.env symlinked to a file under profiles/bob/cache/images, asserting rejection while the target cache file addressed directly remains accepted.

Security evidence:

  • trust boundary: Model-emitted MEDIA: and local-file paths are untrusted sources; validate_media_delivery_path is the validator before gateway adapters open and upload a local file as a native attachment. Profile .env, OAuth, webhook, pairing, and MCP-token paths are secrets that must not cross that boundary, while ordinary generated cache artifacts are intended outputs.
  • source/sink/invariant: Both extraction call paths reach validate_media_delivery_path and the new credential helpers. The claimed invariant is that active, root, and sibling-profile credential-relative paths are rejected after canonicalization, including aliases and directory symlinks, while non-credential profile files and cache artifacts remain deliverable. The early safe-root return at line 1701 violates that invariant for a credential-shaped lexical path resolving into a safe root.
  • current-main reproduction: At bound current main 0a62610f10cc34d696b2239b2c69fa1ba0f1ca63, a normal sibling .env and its directory-symlink form were accepted, reproducing the original exposure, while ordinary sibling notes.md remained accepted.
  • PR-head or patch-replay validation: At bound head eab76cf90e703486c31c63558ea366465d60c7a0, a normal sibling .env and its directory-symlink form were rejected while ordinary sibling notes.md remained accepted; profiles/bob/.env resolving to profiles/bob/cache/images/credential.txt was returned, confirming the remaining safe-root-precedence bypass.
  • positive/negative cases: Ordinary sibling notes.md, a direct cache artifact, and the running-home exception remain accepted; the changed tests reject ordinary sibling .env, MCP-token descendants, unreadable profile enumeration, directory-symlink profiles, mixed-case aliases, and case-distinct hard-denied roots, but the credential-symlink-to-cache case remains accepted.
  • residual bypass search: Lexical versus resolved ancestry, case folding, directory enumeration, profile-directory symlinks, credential-file symlinks, safe-root precedence, strict and non-strict branches, and the running-home exception were assessed. The deterministic bypass for a submitted profile-shaped path is safe-root precedence; a directly addressed resolved symlink target cannot be tied to a sibling profile when neither lexical profile ancestry nor discovered profile identity is available.
  • reviewer validation: The six-commit diff was independently reviewed against bound current main with attachment-sink tracing, credential-policy comparison, focused positive and negative validation, whitespace checking, and production-module byte-compilation; the safe-root ordering flaw remains at the reviewed head.

Not checked:

  • Full test suite
  • CodeRabbit review
  • Case-insensitive filesystem execution

Signed: GPT-5.6-sol-xhigh in Codex

A sibling .env (or other credential-shaped path) that symlinks into
cache/images was accepted because safe-root ran before lexical denial.
@alt-glitch alt-glitch added the needs-repro Bug needs reproduction steps label Aug 2, 2026
@GottZ

GottZ commented Aug 3, 2026

Copy link
Copy Markdown

This was generated by AI during triage.

Summary

Two open PRs address the same sibling-profile credential-exfiltration gap in default media delivery. #47220 adds a narrow four-path denylist without tests, while #70144 applies the full credential file/directory policy with lexical, structural, symlink, enumeration-failure, safe-root, and casefold handling plus regression coverage.

Related pull requests

  • fix(gateway): deny sibling-profile credential paths in media delivery #47220 duplicate — (+14/-0) — n/a: The diff denies only .env, auth.json, credentials, and config.yaml under enumerated sibling profiles, leaving the current OAuth/token stores, credential directories, and regression coverage unaddressed. Despite the MAINTAINER-BOT keep_open verdict, its [contributor:6 commits] review reports a conflict and insufficient evidence against current main; it should be closed as a duplicate of fix(gateway): deny sibling-profile credentials in media delivery #70144.
  • fix(gateway): deny sibling-profile credentials in media delivery #70144 related — (+802/-63) — n/a: The diff applies the complete current credential policy to sibling profiles, preserves lexical and structural ancestry across symlinks and failed enumeration, checks credential-shaped paths before safe-root acceptance, preserves non-credential delivery, and adds targeted tests. The automated keep_open verdict supports this salvage path; keep open for contributor re-review and recorded verification addressing the remaining [contributor:6 commits] review concerns.

Duplicates

#47220 and #70144 address the same sibling-profile media-delivery vulnerability; #70144 is the broader implementation and supersedes #47220.

Suggested consolidation

Keep open with a salvage path for #70144: obtain re-review from the blocking [contributor:6 commits] reviewer and record verification of the updated security boundary and tests. Close #47220 as duplicate of #70144; this differs from the MAINTAINER-BOT keep_open verdict on #47220 because its current diff is conflicting, narrower, and lacks the broader policy and regression coverage present in #70144.

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
    subgraph Dup47220 ["PRs duplicating each other"]
        P47220["PR #47220 (open)"]
        P70144["PR #70144 (open)"]
    end
    class P47220 open
    class P70144 open
    class P70144 target
    click P47220 "https://github.com/NousResearch/hermes-agent/pull/47220"
    click P70144 "https://github.com/NousResearch/hermes-agent/pull/70144"
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 2 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 44 kB of PR diffs, 4 kB of issue/PR text, 29 kB of discussion (12 comments), 0 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

@alt-glitch alt-glitch added needs-repro Bug needs reproduction steps and removed needs-repro Bug needs reproduction steps labels Aug 3, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related to #35939. Both harden native media delivery, but #70144 denies credential-shaped sibling-profile paths while preserving ordinary profile files; #35939 denies all non-cache Hermes-home files. This is a policy/scope choice, not a duplicate.

@egilewski

Copy link
Copy Markdown
Contributor

looks mergeable

The current head addresses the remaining safe-root precedence bypass from the prior review. Sibling-profile credential checks now run before cache/operator allowlisting, so a credential-shaped profiles/<name>/... path cannot be redeemed by a symlink into cache/images or another allowed root. Direct ordinary cache artifacts remain deliverable.

Security evidence:

  • Current-main validation reproduced acceptance of a sibling .env in default mode.
  • PR-head validation rejected ordinary and mixed-case sibling credentials, credential directories, unreadable-profile and directory-symlink paths, and a credential symlink into a profile cache; ordinary sibling notes.md and a directly addressed cache artifact remained deliverable.
  • The PR replayed cleanly onto current main; the changed production source compiled, and the focused test_platform_base.py run passed 102 tests with one platform-dependent skip.

Not checked:

  • Full repository test suite
  • CodeRabbit review
  • Case-insensitive filesystem execution

Signed: GPT-5.6-sol-xhigh in Codex

@alt-glitch alt-glitch added needs-decision Awaiting maintainer decision before any implementation and removed needs-repro Bug needs reproduction steps needs-decision Awaiting maintainer decision before any implementation labels Aug 3, 2026
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 area/profiles Multi-profile isolation, HERMES_HOME scoping comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants