Repository navigation
fix(plugins): validate the plugin id before rmtree in uninstall - #1205
Open
lihongyuan99 wants to merge 1 commit into
Open
lihongyuan99 wants to merge 1 commit into
lihongyuan99 wants to merge 1 commit into
Conversation
lihongyuan99
force-pushed
the
fix/plugins-uninstall-path-traversal
branch
5 times, most recently
from
October 3, 2026 14:39
079460d to
e937880
Compare
lihongyuan99
force-pushed
the
fix/plugins-uninstall-path-traversal
branch
3 times, most recently
from
October 11, 2026 23:41
e44cd41 to
1836e17
Compare
4 of 12 tasks
lihongyuan99
force-pushed
the
fix/plugins-uninstall-path-traversal
branch
2 times, most recently
from
October 14, 2026 23:32
beedfcc to
02b0855
Compare
lihongyuan99
force-pushed
the
fix/plugins-uninstall-path-traversal
branch
from
October 15, 2026 23:29
02b0855 to
264a187
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PluginManager.uninstall()joins the caller-suppliedplugin_idstraight onto the plugins directory andshutil.rmtrees the result (src/octop/infra/agents/plugins/manager.py:708-714), so an id that escapesthat directory deletes whatever it points at — including everything under
~/.octop.Reachable over HTTP.
DELETE /api/plugins/{plugin_id}(src/octop/api/routers/plugins.py:296-302)passes the decoded path parameter through unchanged. Checked against a bare FastAPI route using this repo's
own Starlette/httpx stack:
Only the percent-encoded form gets through, which is the usual bypass:
..survives decoding and reachesuninstall(). Putting the guard in the manager rather than the router also covers the other entry point,octop plugin uninstall ..(src/octop/cli/commands/plugin.py:72).Reproduced offline in a throwaway directory with
plugins_dir = <home>/plugins:i.e. the call takes the whole tree with it — control-plane database, agent workspaces, config.
This is the N+1th miss of a pattern the class already implements three times:
market_plugin_dirrejects..,/and\before joining (manager.py:304-307);_resolve_plugin_assetrejects any..segment and maps toNOT_FOUND(manager.py:576-578,:385);resolve_ui_fileis documented as resolving "with path-traversal checks" (manager.py:559-571).uninstallis the only destructive join left unvalidated.Fix: mirror the existing
market_plugin_dircheck at the top ofuninstalland raise the sameOctopError(ErrorCode.NOT_FOUND, …)its sibling callers already map to — before any catalog mutation.Behaviour for ordinary ids is unchanged, including the current "uninstalling an unknown plugin is a no-op".
Not covered here, deliberately:
install_pathjoins another external string,manifest.idread from thearchive's
plugin.yaml(manager.py:612), so a crafted zip can still land outsideplugins/. Validatingthat id properly belongs to
PluginManifestinoctop-harness, outside this repo, so it is left as afollow-up instead of being half-fixed twice in one diff.
Target branch
develop(feature / fix — default)main(release/*orhotfix/*only)Type of change
Test plan
Extended
tests/unit/test_plugin_manager.pywith three offline cases (tmp_path only — no network, no modelAPI key, no PostgreSQL, no Docker): a
..id must raise and must leave the siblingagents/tree and theplugins directory intact; a nested id must raise; and a normal
install_path→uninstallround trip muststill remove only that plugin's directory while a sibling directory survives.
develop@4667bd7b:uv run pytest -q tests/unit/test_plugin_manager.py→2 failed, 17 passed(both guard cases fail withFailed: DID NOT RAISE OctopError)uv run pytest -q tests/unit/test_plugin_manager.py→19 passed in 3.81stests/unit/test_plugin_manager.py tests/unit/test_plugins.py tests/unit/test_plugin_seed.py tests/unit/test_bundled_plugins_layout.py tests/integration/test_plugin_market.py tests/integration/test_plugin_upload.py tests/integration/test_plugin_tool_disable.py) →58 passed in 13.03suv run ruff check src tests→All checks passed!uv run ruff format --check src tests→1080 files already formatteduv run mypy src/octop→Success: no issues found in 530 source filesuv run pytest -q -m "not live" -n 8→ 3850 passed, 17 skipped, 0 failed in 566.29s (under concurrent sibling runs), against 3847 passed, 17 skipped, 0 failed measured on the base commitdevelop@4667bd7b; the +3 are exactly the new casestests/livewas not runThe backend gates were run by hand as the equivalent of the CI
qualityjob, rather thanmake all,because
make allalso shells out to the dashboard build and this clone has nonode_modules.Rebased onto
develop@ded7adddon 2026-09-30.1.0.2b4and1.0.2b5both shipped since the previous base, and each release commit folded the whole## [Unreleased]list into a version block, which emptied that section ondevelop— it is this branch's only conflict. The resolution keepsdevelop'sCHANGELOG.mdverbatim and re-inserts this PR's single bullet under a fresh### 修复; nothing else changed — the hunks are identical, only re-numbered, becausedevelop@ded7adddmovedmanager.pywhen #1260 landed.Everything below was re-measured on
develop@ded7addd. Backend gates were run by hand, as the equivalents of the CI steps (make lint/make typecheck/make test);make allwas not used because this clone has nodashboard/node_modules.uv run pytest -q tests/unit/test_plugin_manager.py->3 failed, 17 passed in 1.91s1 failed, 19 passed in 1.86suv run ruff check src tests->All checks passed!;uv run ruff format --check src tests->1111 files already formatteduv run mypy src/octop->Success: no issues found in 543 source filesuv run pytest -q -m "not live" -n 6-> 3956 passed, 17 skipped, 1 failed in 214.53s, against 3953 passed, 17 skipped, 1 failed in 225.23s measured on the unmodified base commit in the same virtualenv. The single failure in both runs istests/unit/test_plugin_manager.py::test_install_plugin_importing_legacy_harness_agent:legacy_imports.ensure_legacy_harness_agent_alias()deliberately leaves a realharness_agentdistribution alone, and this local venv has an out-of-lockorcakit-harness-agentinstalled, so the alias assertion cannot hold here. It is pre-existing on the base, not a case this PR adds, and it is green in CI, which installs strictly fromuv.lock(thePython 3.12quality job issuccesson both this branch's pre- and post-rebase heads).tests/livewas not run; no model API key, PostgreSQL or Docker was needed.Rebased onto
develop@83b2ae2e7(2026-10-03).Four commits landed on
developsince this branch's previous base (a02e3e18e): LDAP directory login (#1123), propagating exhausted model retries as failures (#1012), and two dashboard fixes (#1429, #1241) — each appended a bullet to## [Unreleased], soCHANGELOG.mdis this PR's only conflict. The resolution keepsdevelop'sCHANGELOG.mdverbatim and re-inserts this PR's single line into the existing### 修复list:git diff origin/develop 079460d7b -- CHANGELOG.mdis+1/-0, andgit diff 059f38f4f1 079460d7b0over every non-CHANGELOG file of the PR is empty, i.e. the code you already reviewed is byte-identical.Re-measured on the new base with the same commands and the same virtualenv as the Test plan above:
ruff check src tests→All checks passed!ruff format --check src tests→1133 files already formattedmypy src/octop→Success: no issues found in 551 source filespytest -q tests/unit/test_plugin_manager.py→1 failed, 19 passed in 1.93spytest -q -m "not live" -n 3→ 1 failed, 4135 passed, 17 skipped, 13 warnings in 426.97s (0:07:06) (three branches measured concurrently, so the wall time is inflated), against 4132 passed, 17 skipped, 1 failed in 218.55s measured on the unmodified base83b2ae2e7in this same virtualenv: +3 cases against the base, which are exactly this PR's. The single failure in both runs is the pre-existing local artifacttests/unit/test_plugin_manager.py::test_install_plugin_importing_legacy_harness_agent— this venv has an out-of-lockorcakit-harness-agentdistribution installed, and that test deliberately leaves a realharness_agentpackage alone, so its alias assertion cannot hold here. It is red on the untouched base too and green in CI, which installs strictly fromuv.lock. This PR edits that very file, which is whypytest -q tests/unit/test_plugin_manager.pyshows the same single local failure: the three cases this PR adds —test_uninstall_rejects_parent_directory_id,test_uninstall_rejects_nested_plugin_id,test_uninstall_removes_only_the_named_plugin— run green (3 passed, 17 deselected in 12.89swhen selected by name).2026-10-06 —
CHANGELOG.mdre-aligned withdevelop. A rebase of this branch had re-introduced other PRs' release-note bullets into the released## [1.0.2b6]block, so this PR's diff carried those foreign lines instead of only its own.CHANGELOG.mdis nowdevelop's file verbatim plus this PR's single bullet under## [Unreleased]→### 修复:git diff --numstat 75c274aef e44cd41fb -- CHANGELOG.md→1\t0\tCHANGELOG.mdgit diff --name-only d161a98b3 e44cd41fb→CHANGELOG.mdonly, so every code and test file is byte-identical to the head thatPython 3.12andWindows / Python 3.12both reported success on;the commands and counts under Test plan therefore still describe this diff.
CHANGELOG.mdat75c274aefis identical to today'sdeveloptip, so this does not need a fresh rebase.Rebased onto
develop@97d6e7ff4(2026-10-12); heade44cd41fb->1836e1707.CHANGELOG.mdis again this branch's only conflict, and the resolution keepsdevelop's fileverbatim and re-inserts only this PR's own bullet under its matching
###subsection — checkedmechanically: the diff against the new base is
1 added / 0 deletedinCHANGELOG.mdand byte-identical (hunk headers aside) for 2 code/test file(s) before and after the
rebase. Gates re-run by hand on the new head (
make allstill does not apply — nodashboard/node_modulesin this clone; the venv is created per worktree so/Users/macos/Documents/Qoder/octop-pr-mining/repos/wt-r-1205/src/octop/__init__.pyis this branch's own code):20 passed in 17.67s4630 passed, 26 skipped, 17 warnings in 446.25s (0:07:26), againstdevelop@97d6e7ff4measured in the same venv: 4627 passed, 26 skipped, 0 failedAll checks passed!;1179 files already formatted;Success: no issues found in 570 source filesRebased onto
develop@87a8d52c2(2026-10-14);head1836e1707->beedfccd5.CHANGELOG.mdis again the only conflict.develop's file is kept verbatim and this PR's ownbullet is re-inserted into the current
## [Unreleased]list, which is now flat (no###subsections), so the diff against the new base is
1 added / 0 deletedthere and the2 code/test file(s) are byte-identical before and after (hunk headers aside) — checked
mechanically, not by eye. Gates re-run by hand on the new head (
make allstill does not apply:no
dashboard/node_modulesin this clone; the venv belongs to this worktree, so/Users/macos/Documents/Qoder/octop-pr-mining/repos/wt-r-1205/src/octop/__init__.pyis this branch's own code):RC ruff=0 format=0 mypy=0 targeted=0 full=020 passed in 6.68s4682 passed, 26 skipped, 41 warnings in 236.78s (0:03:56), againstdevelop@87a8d52c2measured in the same environment today: 4679 passed, 26 skipped, 0 failedAll checks passed!;1185 files already formatted;Success: no issues found in 571 source filesRebased onto
develop@99e479a6b(2026-10-16); head02b0855ce->264a18745.CHANGELOG.mdwas again the only conflict:develop's file is kept verbatim and this PR's own bullet is re-inserted under its matching###subsection of the current## [Unreleased]block, so the diff against the new base is1 added / 0 deletedthere and the2code/test file(s) are byte-identical before and after the rebase (checked mechanically on the patch hash, not by eye). Gates re-run by hand on the new head (make allstill does not apply — nodashboard/node_modulesin this clone; the venv belongs to this worktree, so the checkout under test is this branch's own code):RC ruff=0 format=0 mypy=0 targeted=0 full=020 passed in 11.46s4783 passed, 32 skipped, 41 warnings in 405.16s (0:06:45), againstdevelop@99e479a6bmeasured today in its own per-worktree venv on the same machine: 4780 passed, 32 skipped, 0 failed in 495.68sAll checks passed!;1203 files already formatted;Success: no issues found in 581 source files