Skip to content

icp-project: a bundle host that treats the archive as untrusted, with filesystem and browser backings #863

Open

Description

@raymondk

Summary

icp-project can produce a bundle (icp project bundle / operations::bundle::create_bundle), but it has no way to consume one safely. On the way back in, a bundle is just a project directory, and the only Host the crate ships (the host feature: HostFileSystem, Builder, Syncer::host, managed networks) trusts that directory the way the CLI trusts the user's own checkout. Every other consumer of bundles — a deploy service, an MCP server, a browser — receives an archive someone else made, and has to rebuild the confinement itself.

We just did exactly that in the imcp2 local MCP server (dfinity/imcp2#216, crates/imcp2-deploy/src/host.rs), and the review there surfaced the concrete gaps. This issue asks for that work to live in icp-cli instead, as a bundle host: the implementations of the seams that treat the archive as untrusted input and enforce the shape create_bundle writes.

What a bundle consumer needs from the library

Each of these is a seam the crate already has; what is missing is the implementation that holds a bundle to its contract.

  1. Bounded extraction. Only regular files and directories are written (a symlink or device node is how an archive reaches past its directory), entry paths cannot escape the target (tar's unpack_in does this), and the sum of entry sizes plus the decompressed stream are capped. The 256 MiB compressed limit we started with bounded nothing on disk, since gzip expands up to ~1000:1.
  2. Path confinement. The manifest's path: fields (wasm sources, init_args/upgrade_args files, environment_variables files, plugin inputs) are joined onto a canister directory with no containment check. An absolute path, .., or a symlink reads any file the process can and, for a variable file, ships it to the canister as a setting. A FileSystem implementation rooted at the extracted directory closes all of these at once, since every path the loader reads itself goes through that seam. The check is lexical (feat(project): a filesystem confined to the project root, for a project from elsewhere #866, files::Confined): join to the root, drop ., apply .., refuse what climbs out or is absolute elsewhere. It does not look at symlinks, because item 1 guarantees there are none in the tree: a bundle is written without them (fix(bundle): hold a bundle to regular files and directories #868) and extraction refuses any entry that is not a regular file or directory. The tree is also the consumer's own while the project loads, with nothing else rewriting it, so there is no check-then-open race to defend. A local recipe template is read by the Resolve seam rather than FileSystem, so the consumer's Resolve must refuse it (item 4). The scratch_dir it hands out has to be inside the root, or a build output cannot be read back through the same fence.
  3. No scripts. Builder runs BuildStep::Script and Syncer::host runs SyncStep::Script as host subprocesses. A bundle never carries one, so a bundle host should refuse them — ideally by walking the loaded project before any phase runs (a script sync step is otherwise only reached after canisters were created and cycles spent), with the Build/ScriptRunner seams refusing again as a backstop. Plugin steps keep running in the wasmtime sandbox.
  4. Bundled wasm only. A Fetch that reads a local source inside the root, verifies the recorded sha256, and refuses remote sources and recipes.
  5. Connected networks only. A managed network cannot be launched by a consumer, so the Access implementation should refuse it with a message that names the way out.
  6. A shape check at load. "Is this archive a bundle?" — manifest at the root, every canister a pre-built step with a checksum, no scripts, no recipes — so a consumer can reject an archive up front instead of discovering it phase by phase.

Shape: one bundle contract, two backings

The crate already splits on the host feature so it can build for wasm32-unknown-unknown. Bundles are deployed from the browser too, so the bundle host should not be host-only: the contract above (items 2–6, and the extraction limits in 1) is the same in both places, and only the backing differs.

A possible layout:

  • icp-project keeps the seams, the operations, and the bundle contract: the shape check, the script refusal, the path-confinement rule (expressed over FileSystem, so it is backing-independent), the size limits.
  • A filesystem-backed crate (or the existing host feature): extraction into a scratch directory on disk, the confined FileSystem over HostFileSystem, id/artifact stores under the extracted .icp.
  • A browser-backed crate: extraction into memory (tar + flate2 both build for wasm), an in-memory FileSystem over the extracted tree (which is confined by construction), id store handed back to the caller rather than written to .icp, randomness from the platform.

Then a consumer — the CLI itself (icp deploy --bundle?), imcp2, a browser deploy page — composes a Host from the bundle crate and supplies only CanisterCalls and the network access it has.

Why upstream

Today each consumer has to reimplement the confinement from the outside, against seams whose contract ("every manifest read goes through FileSystem", "the builder is the only thing that runs scripts") is documented but not enforced for this use. If a future change in icp-project reads a file through std::fs instead of the seam, every downstream fence silently stops covering it. Keeping the bundle host next to the operations is the only place that invariant can be tested.

Reference

The current downstream implementation, for the detail of each item: https://github.com/dfinity/imcp2/blob/deploy-bundle-endpoint/crates/imcp2-deploy/src/host.rs (Root, ConfinedFs, BundledWasm, NoScripts, refuse_scripts, extract_bundle), pinned to icp-cli f4c7b66.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions