Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/agent_sync/publish/agents_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ def publish_agents(
# Use the name as filename, sanitized
safe_name = agent_name.replace("/", "_").replace(" ", "_")
dest = agents_dir / f"{safe_name}.md"
shutil.copy2(Path(agent.path), dest)
shutil.copy2(Path(agent.path), dest, follow_symlinks=False)

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.

security-high high

Using follow_symlinks=False on shutil.copy2 will copy the symbolic link itself rather than the target file's content. This introduces several issues:

  1. Functional Bug: The published git repository will contain a broken symlink pointing to a local path on the developer's machine, meaning the actual content of the agent is not published.
  2. Information Leakage: The symlink's target path (which can contain absolute paths like /Users/username/projects/...) is committed to the public repository, leaking the developer's local username and directory structure.
  3. Platform Compatibility: On Windows, creating symlinks via shutil.copy2 with follow_symlinks=False can fail with OSError if the user does not have symlink creation privileges.

Since the user explicitly selected this agent to publish, we should follow the symlink (the default behavior of shutil.copy2) to ensure the actual content of the agent is copied and published.

Suggested change
shutil.copy2(Path(agent.path), dest, follow_symlinks=False)
shutil.copy2(Path(agent.path), dest)

published_count += 1
else:
console.print(f"[dim] ⚠ Not found: {agent_name}[/dim]")
Expand Down
3 changes: 2 additions & 1 deletion src/agent_sync/publish/external_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,8 @@ def _is_valid_skill_name(name: str) -> bool:
"""
import re
# Match: starts with letter, then letters/numbers/hyphens, ends with letter/number
pattern = r'^[a-z][a-z0-9]*(-[a-z0-9]+)*$'
# Use \Z instead of $ to prevent newline injection
pattern = r'^[a-z][a-z0-9]*(-[a-z0-9]+)*\Z'
return bool(re.match(pattern, name))


Expand Down
45 changes: 19 additions & 26 deletions src/agent_sync/publish/git_publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"""

import shutil
import re
import subprocess
import tempfile
from pathlib import Path
Expand All @@ -25,26 +24,10 @@
'models.json', 'models.yaml', 'config_local.json',
# Environment files
'.env', '.env.*', '*.pem', '*.key',
# Security/Secret patterns
'secrets', 'credentials', 'tokens', 'api_keys', 'mcp-secrets',
]
Comment on lines 24 to 29

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.

security-high high

To securely handle symlinks during directory copying without breaking functionality or leaking local paths, we can define a custom ignore helper. This helper will use shutil.ignore_patterns for standard patterns and additionally ignore any symlink that points outside the source directory. This prevents directory traversal and data leakage of files outside the source directory, while safely copying the contents of internal symlinks.

    'models.json', 'models.yaml', 'config_local.json',
    # Environment files
    '.env', '.env.*', '*.pem', '*.key',
    # Security/Secret patterns
    'secrets', 'credentials', 'tokens', 'api_keys', 'mcp-secrets',
]


def _safe_ignore_patterns(src_dir: Path, patterns: list[str]):
    standard_ignore = shutil.ignore_patterns(*patterns)
    def _ignore(path, names):
        ignored = set(standard_ignore(path, names))
        for name in names:
            full_path = Path(path) / name
            if full_path.is_symlink():
                try:
                    resolved = full_path.resolve()
                    resolved.relative_to(src_dir.resolve())
                except ValueError:
                    ignored.add(name)
        return list(ignored)
    return _ignore



def _ignore_func(*patterns):
"""Create a callable that returns a list of filenames to ignore."""
def _ignore(path, names):
ignored = []
for name in names:
for pattern in patterns:
if pattern.startswith('*.'):
if name.endswith(pattern[1:]):
ignored.append(name)
break
elif pattern.startswith('.'):
if name == pattern or name.startswith(pattern.rstrip('/') + '/'):
ignored.append(name)
break
return ignored
return _ignore

from rich.console import Console

from .base import SourceWithSkills
Expand Down Expand Up @@ -91,11 +74,16 @@ def do_git_publish(
for src_path, dest_name in items:
dest = items_dir / dest_name
if src_path.is_dir():
shutil.copytree(src_path, dest, dirs_exist_ok=True,
ignore=_ignore_func(*DEFAULT_IGNORE_PATTERNS))
shutil.copytree(
src_path,
dest,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns(*DEFAULT_IGNORE_PATTERNS),
symlinks=True,
)
Comment on lines +77 to +83

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.

security-high high

Use the new _safe_ignore_patterns helper to safely copy the directory. By omitting symlinks=True (defaulting to False), shutil.copytree will copy the actual content of safe internal symlinks, while the helper will completely ignore any unsafe symlinks pointing outside the source directory. This prevents both broken symlinks in the repository and out-of-bounds data leakage.

                shutil.copytree(
                    src_path,
                    dest,
                    dirs_exist_ok=True,
                    ignore=_safe_ignore_patterns(src_path, DEFAULT_IGNORE_PATTERNS),
                )

else:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_path, dest)
shutil.copy2(src_path, dest, follow_symlinks=False)

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.

security-high high

Omit follow_symlinks=False to ensure that the actual content of the explicitly selected file is copied and published, rather than committing a broken symlink and leaking local paths.

Suggested change
shutil.copy2(src_path, dest, follow_symlinks=False)
shutil.copy2(src_path, dest)


# Generate README
readme_generator(items_dir, items, repo)
Expand Down Expand Up @@ -162,11 +150,16 @@ def publish_all(
dest_name = f"{source_id}/{skill_name}"
dest = skills_dir / dest_name
if skill.path.is_dir():
shutil.copytree(skill.path, dest, dirs_exist_ok=True,
ignore=_ignore_func(*DEFAULT_IGNORE_PATTERNS))
shutil.copytree(
skill.path,
dest,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns(*DEFAULT_IGNORE_PATTERNS),
symlinks=True,
)
Comment on lines +153 to +159

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.

security-high high

Use the new _safe_ignore_patterns helper to safely copy the skill directory, preventing broken symlinks and out-of-bounds data leakage.

                                shutil.copytree(
                                    skill.path,
                                    dest,
                                    dirs_exist_ok=True,
                                    ignore=_safe_ignore_patterns(skill.path, DEFAULT_IGNORE_PATTERNS),
                                )

else:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(skill.path, dest)
shutil.copy2(skill.path, dest, follow_symlinks=False)

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.

security-high high

Omit follow_symlinks=False to ensure that the actual content of the explicitly selected skill file is copied and published.

Suggested change
shutil.copy2(skill.path, dest, follow_symlinks=False)
shutil.copy2(skill.path, dest)

skills_to_publish.append((skill.path, dest_name))
break

Expand All @@ -188,7 +181,7 @@ def publish_all(
agent = all_agents.get(agent_name)
if agent:
dest = agents_dir / f"{agent_name}.md"
shutil.copy2(Path(agent.path), dest)
shutil.copy2(Path(agent.path), dest, follow_symlinks=False)

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.

security-high high

Omit follow_symlinks=False to ensure that the actual content of the explicitly selected agent file is copied and published.

Suggested change
shutil.copy2(Path(agent.path), dest, follow_symlinks=False)
shutil.copy2(Path(agent.path), dest)

agents_to_publish.append((Path(agent.path), f"agents/{agent_name}.md"))

if agents_to_publish:
Expand Down
3 changes: 2 additions & 1 deletion src/agent_sync/publish/local_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ def _is_valid_skill_name(name: str) -> bool:
"""
import re
# Match: starts with letter, then letters/numbers/hyphens, ends with letter/number
pattern = r'^[a-z][a-z0-9]*(-[a-z0-9]+)*$'
# Use \Z instead of $ to prevent newline injection
pattern = r'^[a-z][a-z0-9]*(-[a-z0-9]+)*\Z'
return bool(re.match(pattern, name))


Expand Down
Loading