Skip to content

🛡️ Sentinel: [security improvement] Harden symlink handling and regex validators#96

Open
calionauta wants to merge 1 commit into
mainfrom
sentinel/harden-symlinks-and-validators-10497176329435694531
Open

🛡️ Sentinel: [security improvement] Harden symlink handling and regex validators#96
calionauta wants to merge 1 commit into
mainfrom
sentinel/harden-symlinks-and-validators-10497176329435694531

Conversation

@calionauta

Copy link
Copy Markdown
Owner

This PR implements security hardening for symlink handling and input validation in agent-sync.

🛡️ Security Improvements

  1. Symlink Content Leakage Protection:

    • Updated all shutil.copytree and shutil.copy2 calls in sync.py, skills.py, git_publish.py, and safe_fallback.py.
    • Now uses symlinks=True and follow_symlinks=False respectively.
    • Impact: Prevents agent-sync from following symbolic links that point outside of the intended skill or agent directories, mitigating the risk of accidental or malicious data exfiltration. Also correctly preserves agent extension symlink structures.
  2. Regex Anchor Hardening:

    • Updated _is_valid_skill_name regular expressions in local_source.py and external_source.py.
    • Changed the end-of-string anchor from $ to \Z.
    • Impact: Prevents newline injection bypasses in skill name validation, ensuring that names like my-skill\n are correctly rejected.

✅ Verification

  • Added regression tests in tests/test_security.py covering both symlink preservation and newline injection.
  • Ran the full test suite (550+ tests), all passing.
  • Verified all shutil copy calls in the modified files have been hardened.

PR created automatically by Jules for task 10497176329435694531 started by @renatocaliari

… validators

Severity: Medium
Vulnerability: Symlink content leakage and regex newline injection.
Impact: Potential exfiltration of sensitive files via malicious symlinks and validation bypass via trailing newlines.
Fix:
- Explicitly set symlinks=True in shutil.copytree and follow_symlinks=False in shutil.copy2 across sync and publish flows.
- Updated _is_valid_skill_name regex anchors from $ to \Z in discovery modules.
Verification: Added regression tests in tests/test_security.py and verified all 550+ tests pass.

Co-authored-by: renatocaliari <1719651+renatocaliari@users.noreply.github.com>
@google-labs-jules

Copy link
Copy Markdown
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request hardens file operations and validation by ensuring that shutil.copytree preserves symlinks (symlinks=True), shutil.copy2 does not follow symlinks (follow_symlinks=False), and regex patterns use \Z instead of $ to prevent newline injection. Security tests were also added to verify these changes. The reviewer noted that Path.is_dir() returns True for symlinks pointing to directories, which can cause shutil.copytree to raise a FileExistsError on subsequent syncs when dirs_exist_ok=True is used. Additionally, broken symlinks are currently ignored. The reviewer suggested explicitly checking for symlinks first, unlinking existing destinations, and copying them using shutil.copy2 with follow_symlinks=False.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/agent_sync/sync.py
Comment on lines 1718 to +1721
if item.is_dir():
shutil.copytree(item, dest, dirs_exist_ok=True, ignore=shutil.ignore_patterns('.git'))
shutil.copytree(item, dest, symlinks=True, dirs_exist_ok=True, ignore=shutil.ignore_patterns('.git'))
else:
shutil.copy2(item, dest)
shutil.copy2(item, 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.

high

In Python, Path.is_dir() returns True for symbolic links pointing to directories. When item is a symlink pointing to a directory, calling shutil.copytree with symlinks=True and dirs_exist_ok=True will attempt to copy the symlink itself using os.symlink. However, shutil.copytree does not respect dirs_exist_ok=True when copying a root symlink, which will raise a FileExistsError if dest already exists (e.g., on subsequent syncs). Additionally, if item is a broken symlink, both is_dir() and is_file() return False, causing the symlink to be silently ignored.

To fix this, check item.is_symlink() first, remove the existing destination if it exists, and copy the symlink using shutil.copy2 with follow_symlinks=False.

                                if item.is_symlink():
                                    if dest.exists() or dest.is_symlink():
                                        dest.unlink()
                                    shutil.copy2(item, dest, follow_symlinks=False)
                                elif item.is_dir():
                                    shutil.copytree(item, dest, symlinks=True, dirs_exist_ok=True, ignore=shutil.ignore_patterns('.git'))
                                else:
                                    shutil.copy2(item, dest, follow_symlinks=False)

Comment thread src/agent_sync/sync.py
Comment on lines 1756 to +1759
if item.is_dir():
shutil.copytree(item, item_dest, dirs_exist_ok=True, ignore=shutil.ignore_patterns('.git'))
shutil.copytree(item, item_dest, symlinks=True, dirs_exist_ok=True, ignore=shutil.ignore_patterns('.git'))
else:
shutil.copy2(item, item_dest)
shutil.copy2(item, item_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.

high

In Python, Path.is_dir() returns True for symbolic links pointing to directories. When item is a symlink pointing to a directory, calling shutil.copytree with symlinks=True and dirs_exist_ok=True will attempt to copy the symlink itself using os.symlink. However, shutil.copytree does not respect dirs_exist_ok=True when copying a root symlink, which will raise a FileExistsError if item_dest already exists (e.g., on subsequent syncs). Additionally, if item is a broken symlink, both is_dir() and is_file() return False, causing the symlink to be silently ignored.

To fix this, check item.is_symlink() first, remove the existing destination if it exists, and copy the symlink using shutil.copy2 with follow_symlinks=False.

Suggested change
if item.is_dir():
shutil.copytree(item, item_dest, dirs_exist_ok=True, ignore=shutil.ignore_patterns('.git'))
shutil.copytree(item, item_dest, symlinks=True, dirs_exist_ok=True, ignore=shutil.ignore_patterns('.git'))
else:
shutil.copy2(item, item_dest)
shutil.copy2(item, item_dest, follow_symlinks=False)
if item.is_symlink():
if item_dest.exists() or item_dest.is_symlink():
item_dest.unlink()
shutil.copy2(item, item_dest, follow_symlinks=False)
elif item.is_dir():
shutil.copytree(item, item_dest, symlinks=True, dirs_exist_ok=True, ignore=shutil.ignore_patterns('.git'))
else:
shutil.copy2(item, item_dest, follow_symlinks=False)

Comment on lines 93 to +98
if src_path.is_dir():
shutil.copytree(src_path, dest, dirs_exist_ok=True,
shutil.copytree(src_path, dest, symlinks=True, dirs_exist_ok=True,
ignore=_ignore_func(*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.

high

In Python, Path.is_dir() returns True for symbolic links pointing to directories. When src_path is a symlink pointing to a directory, calling shutil.copytree with symlinks=True and dirs_exist_ok=True will attempt to copy the symlink itself using os.symlink. However, shutil.copytree does not respect dirs_exist_ok=True when copying a root symlink, which will raise a FileExistsError if dest already exists (e.g., on subsequent syncs). Additionally, if src_path is a broken symlink, both is_dir() and is_file() return False, causing the symlink to be silently ignored.

To fix this, check src_path.is_symlink() first, remove the existing destination if it exists, and copy the symlink using shutil.copy2 with follow_symlinks=False.

Suggested change
if src_path.is_dir():
shutil.copytree(src_path, dest, dirs_exist_ok=True,
shutil.copytree(src_path, dest, symlinks=True, dirs_exist_ok=True,
ignore=_ignore_func(*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)
if src_path.is_symlink():
if dest.exists() or dest.is_symlink():
dest.unlink()
shutil.copy2(src_path, dest, follow_symlinks=False)
elif src_path.is_dir():
shutil.copytree(src_path, dest, symlinks=True, dirs_exist_ok=True,
ignore=_ignore_func(*DEFAULT_IGNORE_PATTERNS))
else:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_path, dest, follow_symlinks=False)

Comment on lines 164 to +169
if skill.path.is_dir():
shutil.copytree(skill.path, dest, dirs_exist_ok=True,
shutil.copytree(skill.path, dest, symlinks=True, dirs_exist_ok=True,
ignore=_ignore_func(*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.

high

In Python, Path.is_dir() returns True for symbolic links pointing to directories. When skill.path is a symlink pointing to a directory, calling shutil.copytree with symlinks=True and dirs_exist_ok=True will attempt to copy the symlink itself using os.symlink. However, shutil.copytree does not respect dirs_exist_ok=True when copying a root symlink, which will raise a FileExistsError if dest already exists (e.g., on subsequent syncs). Additionally, if skill.path is a broken symlink, both is_dir() and is_file() return False, causing the symlink to be silently ignored.

To fix this, check skill.path.is_symlink() first, remove the existing destination if it exists, and copy the symlink using shutil.copy2 with follow_symlinks=False.

Suggested change
if skill.path.is_dir():
shutil.copytree(skill.path, dest, dirs_exist_ok=True,
shutil.copytree(skill.path, dest, symlinks=True, dirs_exist_ok=True,
ignore=_ignore_func(*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)
if skill.path.is_symlink():
if dest.exists() or dest.is_symlink():
dest.unlink()
shutil.copy2(skill.path, dest, follow_symlinks=False)
elif skill.path.is_dir():
shutil.copytree(skill.path, dest, symlinks=True, dirs_exist_ok=True,
ignore=_ignore_func(*DEFAULT_IGNORE_PATTERNS))
else:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(skill.path, dest, follow_symlinks=False)

Comment thread src/agent_sync/sync.py
Comment on lines 2858 to +2861
if skill_item.is_dir():
shutil.copytree(skill_item, dest_skill, dirs_exist_ok=True)
shutil.copytree(skill_item, dest_skill, symlinks=True, dirs_exist_ok=True)
else:
shutil.copy2(skill_item, dest_skill)
shutil.copy2(skill_item, dest_skill, 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.

high

In Python, Path.is_dir() returns True for symbolic links pointing to directories. When skill_item is a symlink pointing to a directory, calling shutil.copytree with symlinks=True and dirs_exist_ok=True will attempt to copy the symlink itself using os.symlink. However, shutil.copytree does not respect dirs_exist_ok=True when copying a root symlink, which will raise a FileExistsError if dest_skill already exists (e.g., on subsequent syncs). Additionally, if skill_item is a broken symlink, both is_dir() and is_file() return False, causing the symlink to be silently ignored.

To fix this, check skill_item.is_symlink() first, remove the existing destination if it exists, and copy the symlink using shutil.copy2 with follow_symlinks=False.

Suggested change
if skill_item.is_dir():
shutil.copytree(skill_item, dest_skill, dirs_exist_ok=True)
shutil.copytree(skill_item, dest_skill, symlinks=True, dirs_exist_ok=True)
else:
shutil.copy2(skill_item, dest_skill)
shutil.copy2(skill_item, dest_skill, follow_symlinks=False)
if skill_item.is_symlink():
if dest_skill.exists() or dest_skill.is_symlink():
dest_skill.unlink()
shutil.copy2(skill_item, dest_skill, follow_symlinks=False)
elif skill_item.is_dir():
shutil.copytree(skill_item, dest_skill, symlinks=True, dirs_exist_ok=True)
else:
shutil.copy2(skill_item, dest_skill, follow_symlinks=False)

Comment thread src/agent_sync/skills.py
Comment on lines 514 to 524
if item.is_dir():
if not dry_run:
shutil.copytree(item, dest)
shutil.copytree(item, dest, symlinks=True)
synced += 1
elif item.is_file():
# Copy non-directory entries (e.g. RETIRED.md manifest).
# These are metadata files that restore the retirement
# state on a fresh machine.
if not dry_run:
shutil.copy2(item, dest)
shutil.copy2(item, dest, follow_symlinks=False)
synced += 1

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.

medium

In Python, Path.is_dir() and Path.is_file() return False for broken symbolic links. If item is a broken symlink, it will be silently ignored and not copied. To ensure all symlinks (including broken ones) are preserved and copied correctly, check item.is_symlink() first.

Suggested change
if item.is_dir():
if not dry_run:
shutil.copytree(item, dest)
shutil.copytree(item, dest, symlinks=True)
synced += 1
elif item.is_file():
# Copy non-directory entries (e.g. RETIRED.md manifest).
# These are metadata files that restore the retirement
# state on a fresh machine.
if not dry_run:
shutil.copy2(item, dest)
shutil.copy2(item, dest, follow_symlinks=False)
synced += 1
if item.is_symlink():
if not dry_run:
shutil.copy2(item, dest, follow_symlinks=False)
synced += 1
elif item.is_dir():
if not dry_run:
shutil.copytree(item, dest, symlinks=True)
synced += 1
elif item.is_file():
# Copy non-directory entries (e.g. RETIRED.md manifest).
# These are metadata files that restore the retirement
# state on a fresh machine.
if not dry_run:
shutil.copy2(item, dest, follow_symlinks=False)
synced += 1

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.

1 participant