Skip to content

fix(skills): skip symlinked resources when loading a skill from disk - #7601

Open
behnam1990adel-hash wants to merge 1 commit into
google:mainfrom
behnam1990adel-hash:fix/skill-symlink-escape
Open

behnam1990adel-hash wants to merge 1 commit into
google:mainfrom
behnam1990adel-hash:fix/skill-symlink-escape

Conversation

@behnam1990adel-hash

Copy link
Copy Markdown

_load_dir walks a skill's references/, assets/, and scripts/ directories with pathlib.Path.rglob("*") and, for every entry where is_file() is true, reads its content with read_text()/read_bytes(). Both calls follow symbolic links transparently, so a symlink committed inside one of those directories, pointing at a file anywhere else on disk, is read into the skill's resources under the symlink's own name — indistinguishable from an ordinary file once loaded. LoadSkillResourceTool then serves that content back verbatim, since it is a plain dict lookup against what _load_dir already populated, with no further check at request time — confirmed end-to-end through the real load_skill_resource tool.

This skips a path that is itself a symlink before is_file() or either read call runs. rglob("*") does not recurse into a symlinked directory (a pathlib default), so this closes the only way a symlink inside these directories could otherwise leak content.

Added test__load_skill_from_dir_skips_symlinked_resources, which reproduces the previous behavior on a real on-disk filesystem and confirms an ordinary (non-symlink) resource is still loaded normally. The existing test suite under tests/unittests/skills/ passes unchanged.

_load_dir walked a skill's references/, assets/, and scripts/
directories with pathlib.Path.rglob("*") and, for every entry where
is_file() was true, read its content with read_text()/read_bytes().
Both calls follow symbolic links transparently, so a symlink
committed inside one of those directories, pointing at a file
anywhere else on disk, was read into the skill's resources under the
symlink's own name, indistinguishable from an ordinary file once
loaded. LoadSkillResourceTool then served that content back verbatim,
since it is a plain dict lookup against what _load_dir already
populated, with no further check at request time.

Skip a path that is itself a symlink before is_file() or either read
call runs. rglob("*") does not recurse into a symlinked directory (a
pathlib default), so this closes the only way a symlink inside these
directories could otherwise leak content.

Added test__load_skill_from_dir_skips_symlinked_resources, which
reproduces the previous behavior on a real on-disk filesystem and
confirms an ordinary (non-symlink) resource is still loaded normally.

@Somay-kousis Somay-kousis left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test__load_skill_from_dir_skips_symlinked_resources fails on the base (assert 'top-secret' is None) and passes on the head. tests/unittests/skills: 105 passed on the head.

The check covers symlinks inside references/, assets/ and scripts/, but not those directories being symlinks themselves. _load_dir(skill_dir / "assets") gets the symlink as directory, rglob("*") walks the target, and none of the yielded entries is a symlink, so everything outside the skill is loaded. That goes against the new comment ("keeps a skill from reading a file that lives outside its own directory").

Probe on this branch, Python 3.12.13, with outside/secret.txt next to the skill:

file symlink in assets/          assets={} references={} scripts=[]
dir symlink inside assets/       assets={} references={} scripts=[]
assets/ itself is a symlink      assets={'secret.txt': 'top-secret'} references={} scripts=[]
references/ itself is a symlink  assets={} references={'secret.txt': 'top-secret'} scripts=[]
scripts/ itself is a symlink     assets={} references={} scripts=['run.py', 'secret.txt']

Returning early when directory.is_symlink() at the top of _load_dir closes these and keeps a skill whose own directory is a symlink loading normally (tried on your branch, then reverted):

assets/ itself is a symlink      assets={} references={} scripts=[]
references/ itself is a symlink  assets={} references={} scripts=[]
scripts/ itself is a symlink     assets={} references={} scripts=[]
symlinked skill dir              assets= {'plain.txt': 'plain'} references= {'ref.md': 'ref'}

tests/unittests/skills still 105 passed with that. Parametrizing the new test over the file-in-dir and dir-itself shapes would cover both.

Probe script: probe_7601.py (outputs probe_7601_head.out, probe_7601_base.out, probe_7601_suggested.out).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants