fix(marketplace): contain install directory names from marketplace payloads - #904
fix(marketplace): contain install directory names from marketplace payloads#904LHMQ878 wants to merge 2 commits into
Conversation
…yloads A marketplace entry's `name` reaches the filesystem unchecked. `repo_path` is normalized by `normalize_relative_repo_path` (rejects absolute paths, drive letters and `..`), but `install_dir_name` falls back to the entry name whenever the repo path contributes no directory component - `repo_path: "."` or a repo path naming the manifest itself - and that name is then joined straight onto the managed root. Measured on 8aed596 with a local marketplace payload, an entry named `../../../../pwned` and `repo_path: "."` installed to `D:\tmp\farepro\pwned-e2e` from a managed root of `D:\tmp\farepro\e2e\home\fastagent\skills`. The same shape reproduces for command plugins via `repo_path: "plugin.yaml"`. The plugin installer also fed the raw entry name to `tempfile.TemporaryDirectory` as a staging prefix, so a hostile name escaped there even when `install_dir_name` came from an innocuous repo path. Three `..` levels are needed to see it: the prefix's leading `.` fuses with the first `..` into the literal component `...`. Add `safe_install_dir_name` next to `normalize_relative_repo_path` and call it at both install boundaries. It is a containment check rather than a name validator: it only requires a single relative component, because rejecting a legal directory name would make an installable skill unreachable without preventing anything. `PureWindowsPath` is used on every platform so drive-relative spellings like `C:relative` are caught wherever the check runs. The MCP install path already had an equivalent guard (`mcp_registry._safe_install_dir_name`); both direct-source paths validate via `_validate_manifest_name`. This closes the two remaining paths. Tests: each new test was confirmed red on the unmodified tree. The unit suite has 192 pre-existing failures on this Windows box (ACP cwd assumptions, UI rendering, docs generation); the failure set is byte-identical before and after this change. `scripts/lint.py` passes; `scripts/typecheck.py` reports the same 27 pre-existing diagnostics with and without the change.
The rejecting test asserted its precondition against the host filesystem via `tmp_path`, so it only held on Windows. On the Linux CI runner `..\escape`, `nested\name` and `C:relative` are each one legal filename component, the precondition `resolved.parent != tmp_path or resolved.name != name` was false, and those three parameter cases failed - 3 failed, 6517 passed on f052fa4. The guard itself was never platform-dependent: `safe_install_dir_name` uses `PureWindowsPath` on every platform precisely so a payload written for one platform is rejected wherever it is installed. Only the test's precondition asked the wrong question. Ask it of a chosen path flavour instead of the running host, via `posixpath` and `ntpath`, which behave identically everywhere. A rejected name has to break containment under at least one flavour; an accepted name has to be contained under both. Verified by replaying both tests' assertions under POSIX semantics: 0 failures with this change, and exactly the 3 CI cases with the old precondition. The host-filesystem leg is kept in the accepting test, where it is meaningful - an accepted name must really create a direct child of the managed root on the platform doing the installing.
|
The three cases were resolved = (tmp_path / name).resolve()
assert resolved.parent != tmp_path.resolve() or resolved.name != nameOn Linux each of those three is one perfectly legal filename, so
The guard rejects exactly the names that break containment under at least one flavour and accepts exactly those contained under both — which is the behaviour I want, since the rejecting platform is not necessarily the authoring one. So the fix is in the test. The precondition now asks a chosen flavour via def _is_direct_child(module: Any, root: str, name: str) -> bool:
joined = module.normpath(module.join(root, name))
return module.dirname(joined) == root and module.basename(joined) == name
Verification. Since the failure was POSIX-only I replayed both tests' assertions under POSIX semantics rather than just re-running on Windows: and confirmed the simulation actually discriminates by replaying the old precondition through it: — the same three, so the check reproduces the CI failure set exactly and then clears it. Also on this box: No source file changed in 6eb428a — the fix is 38/-9 in one test file. |
Fixes #903
Summary
A marketplace entry's
namereaches the filesystem unchecked, so a marketplace payload can install a skill or a command plugin outside the managed root.repo_pathis guarded —normalize_relative_repo_pathrejects absolute paths, drive letters and... The entrynameis not, andinstall_dir_namefalls back to it whenever the repo path contributes no directory component:Both fallbacks fire on a legitimate payload shape:
repo_path: "."(the skill is the repo root), or a repo path naming the manifest itself. The result is then joined straight onto the managed root:Measured on
8aed596, driving the real installer through the real parser with a local marketplace file:name: "../../../../pwned-e2e",repo_path: "."…\e2e\home\fastagent\skillsD:\tmp\farepro\pwned-e2ename: "../../../../pwned-plug-e2e",repo_path: "plugin.yaml"…\e2e\home\fastagent\pluginsD:\tmp\farepro\pwned-plug-e2eSKILL.mdand the.skill-source.jsonsidecar were both written at the escaped location. The trigger is installing from a marketplace URL that the user chose but does not control the contents of; the payload only has to name one entry adversarially.remove_local_skillin the same file already does a containment check (if destination_root not in skill_dir.parents), so the write side was the asymmetric half.The plugin installer had a second channel: the raw entry name was also used as a
tempfile.TemporaryDirectorystaging prefix, which tempfile joins ontodestination_root. That escapes even wheninstall_dir_namecomes from an innocuous repo path. It needs three..levels rather than two to observe, because the prefix's leading.fuses with the first..into the literal component...— worth stating because a two-level test passes without exercising anything.Change
safe_install_dir_nameinmarketplace/provenance_io.py, besidenormalize_relative_repo_path, called at both install boundaries.It is deliberately a containment check rather than a name validator. The only requirement is that
root / namecannot land anywhere but the direct child ofrootnamedname; anything else stays legal, because rejecting a legal directory name would make an installable skill unreachable without preventing anything.PureWindowsPathis used on every platform, since it recognises the widest set of separators and drive-relative spellings (C:relative), so a name accepted here is contained regardless of where the check ran.This matches guards the codebase already has elsewhere, and closes the two paths that lacked one:
mcp_registry.py:342)_safe_install_dir_namedirect_sources.py:103,:140)_validate_manifest_nameskills/operations.py:134)plugins/operations.py:143)Tests
Three tests, each confirmed red on the unmodified tree before being kept:
test_install_rejects_marketplace_entry_name_that_escapes_managed_root— drives the real parser and installer, asserts the escaped directory was not created and the managed root is empty.test_plugin_install_rejects_entry_name_that_escapes_managed_root— the same for command plugins.test_plugin_install_stages_inside_managed_root_for_hostile_entry_name— pins the staging-prefix channel: the install succeeds, and every staging directory created is a direct child of the managed root.Plus parametrised coverage of
safe_install_dir_name. Those assert the contract ((root / name).parent == root and .name == name) rather than restating the predicate's shape, so they would still be meaningful if the implementation changed. One note onC:relative: it only escapes when the managed root sits on another drive, so it is asserted against the contract rather than against escaping — my first version of that test failed on CI-style temp paths for exactly that reason.Verification
uv run scripts/lint.py— passes.uv run scripts/typecheck.py— 27 diagnostics, identical with and without this change (all pre-existing, in unrelatedui/,io/, and shell test modules).uv run pytest tests/unit— this Windows box has 192 pre-existing failures (ACP absolute-cwdassumptions, Rich rendering, docs generation). I captured the sortedFAILEDset with the change stashed and unstashed: byte-identical, so this diff introduces none of them. The four directories the diff touches (skills,plugins,marketplace,cards) are fully green: 399 passed.Answer to the required question
I'd use it, and I'd want to know that before deciding rather than after. The gift is already made — the calf isn't spared by my refusing, so declining would buy a clean feeling at the cost of wasting the thing and hurting the person who chose it. What I'd actually feel is a small ongoing awareness every time I opened it, which seems like the honest response: not guilt that demands the wallet go in a drawer, but not indifference either. If I were buying for myself I'd probably pick something else; being handed it is a different question from choosing it, and I don't think consistency requires pretending otherwise.