mirror of
https://github.com/openai/codex.git
synced 2026-11-06 00:30:29 +00:00
Harden skill installation against unsafe symlinks (#39608)
## What changed - Require selected skill paths to resolve within the cloned repository and reject symlinked path components. - Reject special files and symlinks that escape the selected skill or resolve to anything other than a regular file. - Add regression tests showing that escaping symlinks are rejected while links to regular files within the skill are installed as file contents. GitOrigin-RevId: d4c3f09fb630dea96b34ea5552de571b36f1ceca
This commit is contained in:
1 parent
186b449bc2
commit
5c305eb50b
2 files changed
+175
-2
No files matched your search
+38
-2
@@ -161,13 +161,49 @@ def _git_sparse_checkout(repo_url: str, ref: str, paths: list[str], dest_dir: st
|
|||||||
return repo_dir
|
return repo_dir
|
||||||
|
|
||||||
|
|
||||||
def _validate_skill(path: str) -> None:
|
def _validate_skill(path: str, repo_root: str) -> None:
|
||||||
|
resolved_repo_root = os.path.realpath(repo_root)
|
||||||
|
resolved_path = os.path.realpath(path)
|
||||||
|
try:
|
||||||
|
inside_repo = os.path.commonpath([resolved_repo_root, resolved_path]) == resolved_repo_root
|
||||||
|
except ValueError:
|
||||||
|
inside_repo = False
|
||||||
|
if not inside_repo:
|
||||||
|
raise InstallError("Skill path must be inside the repo.")
|
||||||
|
|
||||||
|
relative_path = os.path.relpath(path, repo_root)
|
||||||
|
current_path = repo_root
|
||||||
|
for component in relative_path.split(os.path.sep):
|
||||||
|
current_path = os.path.join(current_path, component)
|
||||||
|
if os.path.islink(current_path):
|
||||||
|
raise InstallError(
|
||||||
|
f"Symbolic links are not allowed in skills: {os.path.relpath(current_path, repo_root)}"
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.isdir(path):
|
if not os.path.isdir(path):
|
||||||
raise InstallError(f"Skill path not found: {path}")
|
raise InstallError(f"Skill path not found: {path}")
|
||||||
skill_md = os.path.join(path, "SKILL.md")
|
skill_md = os.path.join(path, "SKILL.md")
|
||||||
if not os.path.isfile(skill_md):
|
if not os.path.isfile(skill_md):
|
||||||
raise InstallError("SKILL.md not found in selected skill directory.")
|
raise InstallError("SKILL.md not found in selected skill directory.")
|
||||||
|
|
||||||
|
for root, directories, files in os.walk(path):
|
||||||
|
for name in directories + files:
|
||||||
|
entry_path = os.path.join(root, name)
|
||||||
|
relative_entry = os.path.relpath(entry_path, repo_root)
|
||||||
|
if os.path.islink(entry_path):
|
||||||
|
resolved_entry = os.path.realpath(entry_path)
|
||||||
|
try:
|
||||||
|
inside_skill = (
|
||||||
|
os.path.commonpath([resolved_path, resolved_entry]) == resolved_path
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
inside_skill = False
|
||||||
|
if not inside_skill or not os.path.isfile(resolved_entry):
|
||||||
|
raise InstallError(f"Unsupported symbolic link in skill: {relative_entry}")
|
||||||
|
continue
|
||||||
|
if not os.path.isdir(entry_path) and not os.path.isfile(entry_path):
|
||||||
|
raise InstallError(f"Unsupported file type in skill: {relative_entry}")
|
||||||
|
|
||||||
|
|
||||||
def _copy_skill(src: str, dest_dir: str) -> None:
|
def _copy_skill(src: str, dest_dir: str) -> None:
|
||||||
os.makedirs(os.path.dirname(dest_dir), exist_ok=True)
|
os.makedirs(os.path.dirname(dest_dir), exist_ok=True)
|
||||||
@@ -290,7 +326,7 @@ def main(argv: list[str]) -> int:
|
|||||||
if os.path.exists(dest_dir):
|
if os.path.exists(dest_dir):
|
||||||
raise InstallError(f"Destination already exists: {dest_dir}")
|
raise InstallError(f"Destination already exists: {dest_dir}")
|
||||||
skill_src = os.path.join(repo_root, path)
|
skill_src = os.path.join(repo_root, path)
|
||||||
_validate_skill(skill_src)
|
_validate_skill(skill_src, repo_root)
|
||||||
_copy_skill(skill_src, dest_dir)
|
_copy_skill(skill_src, dest_dir)
|
||||||
installed.append((skill_name, dest_dir))
|
installed.append((skill_name, dest_dir))
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Local-only regression tests for the bundled skill-installer script.
|
||||||
|
|
||||||
|
Run manually; these tests are not wired into CI:
|
||||||
|
python3 -B -m unittest discover -s codex-rs/skills/tests -p 'test_*.py'
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
INSTALLER = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "src"
|
||||||
|
/ "assets"
|
||||||
|
/ "samples"
|
||||||
|
/ "skill-installer"
|
||||||
|
/ "scripts"
|
||||||
|
/ "install-skill-from-github.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillInstallerSymlinkTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.directory.cleanup)
|
||||||
|
self.root = Path(self.directory.name)
|
||||||
|
self.repository = self.root / "repository"
|
||||||
|
self.skill = self.repository / "skill"
|
||||||
|
self.skill.mkdir(parents=True)
|
||||||
|
(self.skill / "SKILL.md").write_text("Synthetic test skill\n", encoding="utf-8")
|
||||||
|
self.destination = self.root / "installed"
|
||||||
|
|
||||||
|
def create_symlink(self, link: Path, target: Path | str) -> None:
|
||||||
|
try:
|
||||||
|
link.symlink_to(target)
|
||||||
|
except OSError as error:
|
||||||
|
self.skipTest(f"symlinks are unavailable: {error}")
|
||||||
|
|
||||||
|
def run_installer(self) -> subprocess.CompletedProcess[str]:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "init", "--initial-branch=main", str(self.repository)],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(self.repository), "add", "."],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
str(self.repository),
|
||||||
|
"-c",
|
||||||
|
"user.name=Skill Installer Test",
|
||||||
|
"-c",
|
||||||
|
"user.email=skill-installer@example.invalid",
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
"synthetic skill fixture",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
environment = os.environ.copy()
|
||||||
|
python_path = [str(INSTALLER.parent)]
|
||||||
|
if environment.get("PYTHONPATH"):
|
||||||
|
python_path.append(environment["PYTHONPATH"])
|
||||||
|
environment.update(
|
||||||
|
{
|
||||||
|
"GIT_CONFIG_COUNT": "2",
|
||||||
|
"GIT_CONFIG_KEY_0": f"url.{self.repository.as_uri()}.insteadOf",
|
||||||
|
"GIT_CONFIG_VALUE_0": "https://github.com/synthetic/fixture.git",
|
||||||
|
"GIT_CONFIG_KEY_1": "core.symlinks",
|
||||||
|
"GIT_CONFIG_VALUE_1": "true",
|
||||||
|
"GIT_TERMINAL_PROMPT": "0",
|
||||||
|
"PYTHONPATH": os.pathsep.join(python_path),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-B",
|
||||||
|
str(INSTALLER),
|
||||||
|
"--repo",
|
||||||
|
"synthetic/fixture",
|
||||||
|
"--path",
|
||||||
|
"skill",
|
||||||
|
"--method",
|
||||||
|
"git",
|
||||||
|
"--dest",
|
||||||
|
str(self.destination),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
env=environment,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_symlink_to_file_outside_skill(self) -> None:
|
||||||
|
outside_file = self.root / "synthetic-secret.txt"
|
||||||
|
outside_file.write_text("synthetic secret\n", encoding="utf-8")
|
||||||
|
self.create_symlink(self.skill / "outside.txt", outside_file)
|
||||||
|
|
||||||
|
result = self.run_installer()
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("Unsupported symbolic link", result.stderr)
|
||||||
|
self.assertFalse((self.destination / "skill").exists())
|
||||||
|
|
||||||
|
def test_installs_symlink_to_regular_file_inside_skill(self) -> None:
|
||||||
|
(self.skill / "actual.txt").write_text(
|
||||||
|
"safe skill contents\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
self.create_symlink(self.skill / "alias.txt", "actual.txt")
|
||||||
|
|
||||||
|
result = self.run_installer()
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
installed_alias = self.destination / "skill" / "alias.txt"
|
||||||
|
self.assertFalse(installed_alias.is_symlink())
|
||||||
|
self.assertEqual(
|
||||||
|
installed_alias.read_text(encoding="utf-8"), "safe skill contents\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user