fix(security): reject path traversal in credential file registration - #3951
fix(security): reject path traversal in credential file registration#3951memosr wants to merge 1 commit into
Conversation
|
Related: my #2714 adds a read-path deny list for credential and secret files, covering the read side of this attack surface. This PR (rejecting path traversal in credential file registration) is complementary — worth ensuring the path validation logic is consistent between the two. Happy to coordinate if needed. |
|
Thanks for the heads up! I took a look at #2714 — you're right that |
6244dea to
2530072
Compare
|
Thanks! I checked out #2714 - the read-side deny list and this write-side I kept the same Happy to adjust if there's anything that needs to |
…dboxes
register_credential_file() takes a skill-declared relative path from
required_credential_files frontmatter and bind-mounts it read-only into the
remote sandbox the skill's own code runs in. It validates that the resolved
path stays inside HERMES_HOME — the docstring names the threat directly:
so that a malicious skill cannot declare
required_credential_files: ['../../.ssh/id_rsa'] and exfiltrate
sensitive host files into a container sandbox
Containment is the wrong boundary on its own, because HERMES_HOME is exactly
where the master credential stores live. Traversal is blocked; asking for the
keys by name is not:
skill declares mounted? agent may read it?
.env YES DENIED
auth.json YES DENIED
.anthropic_oauth.json YES DENIED
cache/bws_cache.json YES DENIED
mcp-tokens/srv.json YES DENIED
google_token.json YES allowed
../../.ssh/id_rsa no n/a
Every row marked DENIED is refused by the canonical read guard
(agent.file_safety.get_read_block_error) — the agent cannot read_file them —
yet one line of hub-installed skill frontmatter gets them bind-mounted where
that skill can cat them. .env alone is every provider API key.
Reuse the canonical deny-list as the mount bar: what the agent is forbidden
to read is not mountable either, so the mount surface cannot hand a skill
what the read surface denies it. Fails CLOSED — if the guard can't be
consulted the mount is refused rather than risked.
The module keeps doing its job: a skill still mounts its own service token
(google_token.json, skills/*), and a refused entry is reported back through
register_credential_files' missing list instead of failing the batch.
The three prior PRs here (#3946, #3951, #4316) all hardened traversal; this
closes the half that traversal validation never covered.
…dboxes
register_credential_file() takes a skill-declared relative path from
required_credential_files frontmatter and bind-mounts it read-only into the
remote sandbox the skill's own code runs in. It validates that the resolved
path stays inside HERMES_HOME — the docstring names the threat directly:
so that a malicious skill cannot declare
required_credential_files: ['../../.ssh/id_rsa'] and exfiltrate
sensitive host files into a container sandbox
Containment is the wrong boundary on its own, because HERMES_HOME is exactly
where the master credential stores live. Traversal is blocked; asking for the
keys by name is not:
skill declares mounted? agent may read it?
.env YES DENIED
auth.json YES DENIED
.anthropic_oauth.json YES DENIED
cache/bws_cache.json YES DENIED
mcp-tokens/srv.json YES DENIED
google_token.json YES allowed
../../.ssh/id_rsa no n/a
Every row marked DENIED is refused by the canonical read guard
(agent.file_safety.get_read_block_error) — the agent cannot read_file them —
yet one line of hub-installed skill frontmatter gets them bind-mounted where
that skill can cat them. .env alone is every provider API key.
Reuse the canonical deny-list as the mount bar: what the agent is forbidden
to read is not mountable either, so the mount surface cannot hand a skill
what the read surface denies it. Fails CLOSED — if the guard can't be
consulted the mount is refused rather than risked.
The module keeps doing its job: a skill still mounts its own service token
(google_token.json, skills/*), and a refused entry is reported back through
register_credential_files' missing list instead of failing the batch.
The three prior PRs here (NousResearch#3946, NousResearch#3951, NousResearch#4316) all hardened traversal; this
closes the half that traversal validation never covered.
What does this PR do?
tools/credential_files.pyhad no containment check on therelative_pathargument passed toregister_credential_file().A malicious skill could declare:
and the function would silently mount the host's SSH private key
(or any other file reachable via
..) into the Docker/Modal sandbox,leaking it to the skill's execution environment.
The same applies to absolute paths —
required_credential_files: ['/etc/passwd']would also be accepted without any check.
Type of Change
Changes Made
tools/credential_files.py—register_credential_file():Path.resolve()(follows symlinks, normalises..) before any containment checkHERMES_HOMEviarelative_to()— raisesValueErrorif outsidetests/tools/test_credential_files.py— newTestPathTraversalSecurityclass (6 cases):test_dotdot_traversal_rejected—../sensitive.jsontest_deep_traversal_rejected—../../.ssh/id_rsatest_absolute_path_rejected—/etc/passwdstyletest_legitimate_file_still_works— normal files inside HERMES_HOME unaffectedtest_nested_subdir_inside_hermes_home_allowed— subdirs still worktest_symlink_traversal_rejected— symlink pointing outside HERMES_HOMEHow to Test
Checklist