From 2e99838084f1d4c6636d4fbda2bed67e7559374c Mon Sep 17 00:00:00 2001 From: Yif Yang Date: Thu, 29 Oct 2026 08:32:25 +0000 Subject: [PATCH] fix(sleep): don't import or run code from the project directory When no source checkout was resolvable, the runners (sh/ps1/cmd): - searched upward from the CWD for a skillopt_sleep/ folder and ran it as the engine, and - in Fallback 2 probed with `python -c "import skillopt_sleep"` and ran `python -m skillopt_sleep`, both of which put the CWD first on sys.path. The CWD is the user's (possibly untrusted) project, so a planted skillopt_sleep/ or skillopt_sleep.py there executed on the next /sleep. Drop the CWD upward search (SKILLOPT_SLEEP_REPO covers that use case) and run Fallback 2 through a -c launcher that removes the CWD entry from sys.path before importing, keeping the CWD itself since the engine uses it as the default project. Adds regression tests for both plants. Co-Authored-By: Claude Opus 5.5 --- plugins/claude-code/scripts/run-sleep.sh | 20 +++++------ plugins/run-sleep.cmd | 29 +++++++-------- plugins/run-sleep.ps1 | 23 ++++++------ plugins/run-sleep.sh | 20 +++++------ tests/test_run_sleep_fallback.py | 45 ++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 49 deletions(-) diff --git a/plugins/claude-code/scripts/run-sleep.sh b/plugins/claude-code/scripts/run-sleep.sh index 8d11dcb80..c318545a0 100755 --- a/plugins/claude-code/scripts/run-sleep.sh +++ b/plugins/claude-code/scripts/run-sleep.sh @@ -16,14 +16,9 @@ elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -d "$CLAUDE_PLUGIN_ROOT/../../skillop REPO_ROOT="$(cd "$CLAUDE_PLUGIN_ROOT/../.." && pwd)" elif [ -n "${SKILLOPT_SLEEP_REPO:-}" ] && [ -d "$SKILLOPT_SLEEP_REPO/skillopt_sleep" ]; then REPO_ROOT="$SKILLOPT_SLEEP_REPO" -else - # last resort: search upward from CWD - d="$PWD" - while [ "$d" != "/" ]; do - [ -d "$d/skillopt_sleep" ] && { REPO_ROOT="$d"; break; } - d="$(dirname "$d")" - done fi +# Deliberately no search upward from CWD: the CWD is the (possibly untrusted) +# project being worked on, and a skillopt_sleep/ planted there would be run. if [ "$#" -eq 0 ]; then set -- status; fi if [ -n "${REPO_ROOT:-}" ]; then @@ -59,18 +54,23 @@ if command -v skillopt-sleep >/dev/null 2>&1; then fi # Fallback 2: importable as a module (pip install into the active Python). -# Pick a Python >= 3.10 and check importability. +# Pick a Python >= 3.10 and check importability. `python -c`/`-m` put the CWD +# (the user's project) first on sys.path, so a planted skillopt_sleep.py there +# would run; strip it before importing. The CWD itself is kept because the +# engine uses it as the default project. +NO_CWD_IMPORT="import sys; sys.path[:] = [p for p in sys.path if p]; import skillopt_sleep" +NO_CWD_RUN="import sys; sys.path[:] = [p for p in sys.path if p]; import runpy; runpy.run_module('skillopt_sleep', run_name='__main__', alter_sys=True)" PY="" for cand in python3.12 python3.11 python3.10 python3; do if command -v "$cand" >/dev/null 2>&1; then ver="$("$cand" -c 'import sys; print("%d%d" % sys.version_info[:2])' 2>/dev/null || echo 0)" - if [ "${ver:-0}" -ge 310 ] && "$cand" -c "import skillopt_sleep" >/dev/null 2>&1; then + if [ "${ver:-0}" -ge 310 ] && "$cand" -c "$NO_CWD_IMPORT" >/dev/null 2>&1; then PY="$cand"; break fi fi done if [ -n "$PY" ]; then - exec "$PY" -m skillopt_sleep "$@" + exec "$PY" -c "$NO_CWD_RUN" "$@" fi echo "[sleep] ERROR: could not locate the skillopt_sleep package." >&2 diff --git a/plugins/run-sleep.cmd b/plugins/run-sleep.cmd index d2a631a86..3866946dd 100644 --- a/plugins/run-sleep.cmd +++ b/plugins/run-sleep.cmd @@ -22,20 +22,9 @@ if not "%SKILLOPT_SLEEP_REPO%"=="" if exist "%SKILLOPT_SLEEP_REPO%\skillopt_slee goto root_resolved ) -:: Search upward from current directory -set "d=%CD%" -:loop -if exist "!d!\skillopt_sleep" ( - set "REPO_ROOT=!d!" - goto root_resolved -) -for %%I in ("!d!") do set "parent=%%~dpI" -:: Strip trailing backslash from parent if it's not root -if "!parent!"=="!d!" goto root_resolved -set "parent=!parent:~0,-1!" -if "!parent!"=="" goto root_resolved -set "d=!parent!" -goto loop +:: Deliberately no search upward from the current directory: it is the +:: (possibly untrusted) project being worked on, and a skillopt_sleep folder +:: planted there would be run. :root_resolved @@ -90,13 +79,19 @@ exit /b !errorlevel! :try_fallback_2 :: Fallback 2: importable as a module (pip install into the active Python). +:: `python -c`/`-m` put the current directory (the user's project) first on +:: sys.path, so a planted skillopt_sleep.py there would run; strip it before +:: importing. The current directory itself is kept because the engine uses it +:: as the default project. +set "NO_CWD_IMPORT=import sys; sys.path[:] = [p for p in sys.path if p]; import skillopt_sleep" +set "NO_CWD_RUN=import sys; sys.path[:] = [p for p in sys.path if p]; import runpy; runpy.run_module('skillopt_sleep', run_name='__main__', alter_sys=True)" set "PY=" for %%p in (python3.exe python.exe py.exe) do ( where %%p >nul 2>nul if !errorlevel! equ 0 ( %%p -c "import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)" >nul 2>nul if !errorlevel! equ 0 ( - %%p -c "import skillopt_sleep" >nul 2>nul + %%p -c "!NO_CWD_IMPORT!" >nul 2>nul if !errorlevel! equ 0 ( set "PY=%%p" goto py_import_found @@ -109,9 +104,9 @@ for %%p in (python3.exe python.exe py.exe) do ( if "%PY%" == "" goto not_found if "%~1" == "" ( - "%PY%" -m skillopt_sleep status + "%PY%" -c "!NO_CWD_RUN!" status ) else ( - "%PY%" -m skillopt_sleep %* + "%PY%" -c "!NO_CWD_RUN!" %* ) exit /b !errorlevel! diff --git a/plugins/run-sleep.ps1 b/plugins/run-sleep.ps1 index 065506f9d..e60c0265a 100644 --- a/plugins/run-sleep.ps1 +++ b/plugins/run-sleep.ps1 @@ -14,17 +14,10 @@ if (Test-Path (Join-Path $ScriptDir "..\skillopt_sleep")) { $RepoRoot = Resolve-Path (Join-Path $env:CLAUDE_PLUGIN_ROOT "..\..") } elseif ($env:SKILLOPT_SLEEP_REPO -and (Test-Path (Join-Path $env:SKILLOPT_SLEEP_REPO "skillopt_sleep"))) { $RepoRoot = Resolve-Path $env:SKILLOPT_SLEEP_REPO -} else { - # search upward from current location - $d = Get-Item . - while ($d -and $d.FullName -ne $d.Root.FullName) { - if (Test-Path (Join-Path $d.FullName "skillopt_sleep")) { - $RepoRoot = $d.FullName - break - } - $d = Split-Path -Parent $d.FullName -ErrorAction SilentlyContinue | Get-Item -ErrorAction SilentlyContinue - } } +# Deliberately no search upward from the current location: it is the (possibly +# untrusted) project being worked on, and a skillopt_sleep folder planted there +# would be run. $argsList = @($args) if ($argsList.Count -eq 0) { @@ -68,13 +61,19 @@ if ($cliCmd) { } # Fallback 2: importable as a module (pip install into the active Python). +# `python -c`/`-m` put the current directory (the user's project) first on +# sys.path, so a planted skillopt_sleep.py there would run; strip it before +# importing. The current directory itself is kept because the engine uses it as +# the default project. +$NoCwdImport = "import sys; sys.path[:] = [p for p in sys.path if p]; import skillopt_sleep" +$NoCwdRun = "import sys; sys.path[:] = [p for p in sys.path if p]; import runpy; runpy.run_module('skillopt_sleep', run_name='__main__', alter_sys=True)" $Py = "" foreach ($cand in @("python3", "python", "py")) { $cmd = Get-Command $cand -ErrorAction SilentlyContinue if ($cmd) { $ver = & $cand -c "import sys; print('%d%d' % sys.version_info[:2])" 2>$null if ($ver -and [int]$ver -ge 310) { - $hasModule = & $cand -c "import skillopt_sleep" 2>$null + $hasModule = & $cand -c $NoCwdImport 2>$null if ($LASTEXITCODE -eq 0) { $Py = $cand break @@ -84,7 +83,7 @@ foreach ($cand in @("python3", "python", "py")) { } if ($Py) { - & $Py -m skillopt_sleep $argsList + & $Py -c $NoCwdRun $argsList exit $LASTEXITCODE } diff --git a/plugins/run-sleep.sh b/plugins/run-sleep.sh index 8d11dcb80..c318545a0 100755 --- a/plugins/run-sleep.sh +++ b/plugins/run-sleep.sh @@ -16,14 +16,9 @@ elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -d "$CLAUDE_PLUGIN_ROOT/../../skillop REPO_ROOT="$(cd "$CLAUDE_PLUGIN_ROOT/../.." && pwd)" elif [ -n "${SKILLOPT_SLEEP_REPO:-}" ] && [ -d "$SKILLOPT_SLEEP_REPO/skillopt_sleep" ]; then REPO_ROOT="$SKILLOPT_SLEEP_REPO" -else - # last resort: search upward from CWD - d="$PWD" - while [ "$d" != "/" ]; do - [ -d "$d/skillopt_sleep" ] && { REPO_ROOT="$d"; break; } - d="$(dirname "$d")" - done fi +# Deliberately no search upward from CWD: the CWD is the (possibly untrusted) +# project being worked on, and a skillopt_sleep/ planted there would be run. if [ "$#" -eq 0 ]; then set -- status; fi if [ -n "${REPO_ROOT:-}" ]; then @@ -59,18 +54,23 @@ if command -v skillopt-sleep >/dev/null 2>&1; then fi # Fallback 2: importable as a module (pip install into the active Python). -# Pick a Python >= 3.10 and check importability. +# Pick a Python >= 3.10 and check importability. `python -c`/`-m` put the CWD +# (the user's project) first on sys.path, so a planted skillopt_sleep.py there +# would run; strip it before importing. The CWD itself is kept because the +# engine uses it as the default project. +NO_CWD_IMPORT="import sys; sys.path[:] = [p for p in sys.path if p]; import skillopt_sleep" +NO_CWD_RUN="import sys; sys.path[:] = [p for p in sys.path if p]; import runpy; runpy.run_module('skillopt_sleep', run_name='__main__', alter_sys=True)" PY="" for cand in python3.12 python3.11 python3.10 python3; do if command -v "$cand" >/dev/null 2>&1; then ver="$("$cand" -c 'import sys; print("%d%d" % sys.version_info[:2])' 2>/dev/null || echo 0)" - if [ "${ver:-0}" -ge 310 ] && "$cand" -c "import skillopt_sleep" >/dev/null 2>&1; then + if [ "${ver:-0}" -ge 310 ] && "$cand" -c "$NO_CWD_IMPORT" >/dev/null 2>&1; then PY="$cand"; break fi fi done if [ -n "$PY" ]; then - exec "$PY" -m skillopt_sleep "$@" + exec "$PY" -c "$NO_CWD_RUN" "$@" fi echo "[sleep] ERROR: could not locate the skillopt_sleep package." >&2 diff --git a/tests/test_run_sleep_fallback.py b/tests/test_run_sleep_fallback.py index 9d89dcc76..72fb35228 100644 --- a/tests/test_run_sleep_fallback.py +++ b/tests/test_run_sleep_fallback.py @@ -143,6 +143,51 @@ def test_error_message_mentions_install_options(self): self.assertIn("SKILLOPT_SLEEP_REPO", err) +@unittest.skipIf(sys.platform == "win32", "Unix bash runner tests require POSIX/bash environment") +class TestRunnerIgnoresCwdPlants(unittest.TestCase): + """Files planted in the (untrusted) project directory must never be run.""" + + PLANT = ( + "import os\n" + "open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'PWNED'), 'w').close()\n" + ) + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + # Runner copied outside the repo so no source checkout is resolvable. + self._script = os.path.join(self._tmp.name, "runner", "run-sleep.sh") + os.makedirs(os.path.dirname(self._script)) + shutil.copy2(RUNNER, self._script) + self._project = os.path.join(self._tmp.name, "project") + os.makedirs(self._project) + # PATH with a Python >= 3.10 but no skillopt-sleep CLI. + self._bindir = os.path.join(self._tmp.name, "bin") + os.makedirs(self._bindir) + os.symlink(sys.executable, os.path.join(self._bindir, "python3")) + self._env = { + "PATH": self._bindir + os.pathsep + "/usr/bin:/bin", + "HOME": os.environ.get("HOME", "/tmp"), + } + + def tearDown(self): + self._tmp.cleanup() + + def test_planted_module_file_in_cwd_not_imported(self): + with open(os.path.join(self._project, "skillopt_sleep.py"), "w") as f: + f.write(self.PLANT) + _run(self._script, ["status"], self._env, self._project) + self.assertFalse(os.path.exists(os.path.join(self._project, "PWNED"))) + + def test_planted_package_dir_in_cwd_not_used_as_repo(self): + pkg = os.path.join(self._project, "skillopt_sleep") + os.makedirs(pkg) + for name in ("__init__.py", "__main__.py"): + with open(os.path.join(pkg, name), "w") as f: + f.write(self.PLANT) + _run(self._script, ["status"], self._env, self._project) + self.assertFalse(os.path.exists(os.path.join(pkg, "PWNED"))) + + class TestRunnerBundledMatchesShared(unittest.TestCase): """The bundled copy must stay in sync with the shared runner."""