You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
imcp2-deploy 1/3: an icp-project host for a bundle, treated as untrusted input - #230
First of three stacked PRs that bring icp deploy to the local MCP server through the icp CLI's own project library (icp-project, the code behind icp deploy). This one adds the crate and the library's Host for a bundle, the .tar.gz that icp project bundle writes. Nothing drives it yet; the pipeline is #231 in the stack and the MCP tools #232.
The bundle is untrusted input. A bundle is handed over by an agent, so the host takes it for exactly what a bundle is and no more:
extract_bundle unpacks it into a scratch directory under a compressed-size limit (256 MiB) and a decompressed-stream limit (1 GiB), refusing symlinks and other non-file entries, more than 250,000 files and directories (counting the parents an entry's path implies), entry paths over 4096 bytes, and anything under .icp/ in any letter case, the id and artifact state a deploy seeds itself.
The FileSystem the library reads the project through is the library's own files::Confined, the host filesystem held to the extraction directory: the root is opened once as a directory handle and every read is made beneath it, so the operating system refuses a path that leaves the root, by climbing, by being absolute elsewhere or by a symlink; on top of that, a path through a symlink is refused wherever it leads. The loader reads every path a manifest names through that one seam, so a manifest can name only what the archive brought with it. The project loader reads through a metered view of it, with a 256 MiB budget on what it retains.
The loaded project is vetted by the library's untrusted rule (icp_project::untrusted::Vetted, what icp deploy --untrusted applies): each build step a pre-built module, each sync step a plugin, the file each names a regular file the confined filesystem will give, and what a plugin step hands its plugin a directory or regular file there too. A script step, a module or plugin fetched from a URL, or a path that is not such a file fails the load, before any phase. No recipe is fetched (untrusted::NoRecipes, the library's own): a recipe is read or fetched while the project loads, so a project naming one fails to load, naming the recipe.
IcMainnet is the network seam: the reserved ic network as the loader supplies it (that name, the mainnet endpoint, the mainnet root key), nothing else. A manifest may declare a network of any other name that claims the mainnet root key over an endpoint of its own; refused.
BundledWasm serves local wasm sources from the bundle, sha256 verified. NoScripts is the script runner the syncer is composed with; it spawns nothing, since the vetted loader admits no script step.
check_bundle validates the whole bundle before a deploy would run: one pre-built step per canister naming a wasm in the archive that hashes to its recorded checksum (and, if gzip-compressed, expands to no more than 100 MiB, since the library gunzips it in memory unbounded), canister names that differ by more than letter case (the artifact store keys build output by name on disk), every sync plugin in the archive with the bytes its files: declare held to a sum, an environment on ic. A canister directory outside the root needs no check of its own any more: the confined loader does not find it, and the project does not load.
Relationship to icp-cli. The confinement used to live here, as an index-backed filesystem of this crate's own. The rule is one, split three ways upstream (dfinity/icp-cli#863, issue #865): the user's own project may run, fetch and link whatever it likes; icp project bundle writes an archive of regular files and directories with every step spelled out (#868); and a consumer of a bundle it does not trust loads it as untrusted (#873, on #868: files::Confined on cap-std, untrusted::NoRecipes and untrusted::Vetted). This PR pins the four icp-cli crates to icp-cli's main at the merge of #873 (8f98455, the squash of #873 on #868's merge). The Cargo.toml comment says so.
Why a new crate, and why unpublished.icp-project and its siblings are publish = false upstream, so they can only be git dependencies, and crates.io refuses any crate that carries one. imcp2-core and imcp2 publish there, so this is its own publish = false crate, composed into imcp2-local alone (in #232). The four icp-cli crates are pinned to one rev and must be bumped together. icp-sync-plugin brings wasmtime into the workspace, so builds are slower. The manifest carries the crate's full dependency list so the lockfile lands once; rmcp and base64 are used only from #232.
Most of host.rs exists because icp-project ships no host for a bundle. dfinity/icp-cli#863 asks for one upstream, with filesystem and browser backings, so this could later shrink to the MCP glue.
crates/imcp2-deploy (new, publish = false, version 0.6.1 like the other crates): Cargo.toml, src/lib.rs (crate doc, pub mod host), src/host.rs.
Workspace Cargo.toml adds the member; Cargo.lock gains the icp-cli crates and their tree (plus semver-compatible patch bumps of anyhow, toml, wasm-encoder/wasmparser/wast, and wasmtime 49.0.5 from the newer icp-cli pin).
Testing
cargo build --locked --workspace --all-targets
cargo test --locked --workspace --all-targets (the 23 in the new crate are new)
npm test --prefix monitoring/mcp-status (dashboard unchanged)
The tests are offline. The network seam: only the reserved ic is served, and it resolves to the pinned root key; a managed network, a fetched key, another name with the mainnet key, and the mainnet key over a loopback endpoint are refused. Extraction: no manifest, not an archive, oversize compressed, oversize decompressed, a symlink entry, an entry under .icp/ spelled plainly, as ./.icp/ and as .ICP/, too many files and directories (implied parents counted), an entry path past the length limit. A gzipped module is held to its limit. The bundle is served through the confined filesystem by any spelling inside the root, and absolute or climbing paths are refused as outside it; the filesystem's own spelling and symlink cases are tested upstream in icp-cli#873. The load refuses a script step, a remote module and a recipe, naming each; a module named through a symlink made after extraction, out of the root, within it or dangling, fails the load as not a regular file of the project, and a read through the link is refused naming it; a directory named as a module is refused; a root named through a link holds the project. A sync step's declared dirs: and files: are held to the bundle at the load and to a byte sum at the check. The loader's read budget is tested with two canisters naming one argument file.
Adds an unpublished imcp2-deploy crate implementing an icp-project host for untrusted project bundles. This establishes the foundation for the deployment pipeline and local MCP tools in the next two PRs.
Changes:
Adds bounded archive extraction, an index-backed filesystem, and bundle validation.
Implements network, wasm, and build adapters, with script execution refused and offline tests included.
Registers the crate and locks the pinned icp-cli dependencies, including the Wasmtime runtime.
File
Description
crates/imcp2-deploy/src/lib.rs
Documents the crate’s scope and exports the host module.
crates/imcp2-deploy/src/host.rs
Implements bundle extraction, host adapters, validation, and tests.
crates/imcp2-deploy/Cargo.toml
Defines the unpublished crate and pins icp-cli dependencies.
Cargo.toml
Adds the workspace member and changes root version declarations.
Cargo.lock
Records the new dependency tree and package version updates.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Unresolved critical flaws allow case-insensitive aliases to introduce unintended deployment targets or substitute Wasm artifacts, while unbounded decompression and quadratic index scans expose the server to memory and CPU exhaustion.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Critical resource-budget gaps in archive extraction, plugin execution, and project loading allow crafted bundles to exhaust server memory, disk space, or inodes.
No open issues 🎉
3 previously missed issues
In code that hasn't changed since last review
Repeated file-backed arguments bypass the project-loading memory limit
crates/imcp2-deploy/src/host.rs:361
Project loading can exhaust memory before check_bundle runs: the pinned loader reads each file-backed argument into a separate owned value retained by its canister. Fifty canisters referencing the same 100 MiB binary argument retain roughly 5 GiB despite staying within the extraction limits. Enforce a cumulative project-loading read budget, counting repeated reads, before allocating and returning these buffers to ProjectLoadImpl.
Plugin execution lacks a store memory resource limiter
crates/imcp2-deploy/src/host.rs:744
The pinned Wasmtime runner executes bundle-supplied plugins in the server process with stack and epoch limits but no store resource limiter. A small component can allocate and touch multiple GiB of linear memory, potentially exhausting server memory before the timeout; archive limits do not constrain these allocations. Use a runner with aggregate memory, table, and instance budgets, or run plugins in an OS-resource-limited subprocess.
Implicit archive directories bypass the filesystem-object limit
crates/imcp2-deploy/src/host.rs:827
The entry cap counts archive records, but unpack_in also creates every missing parent directory. A bundle of empty files beneath distinct, deep directory chains can stay within both byte limits and the entry cap while creating millions of directories, exhausting disk space or inodes. Count distinct implicit parent directories against a filesystem-object budget before unpacking each entry.
The three "previously missed" findings in the last review, addressed in ec3c3c3:
Repeated file-backed arguments bypass the project-loading memory limit. The project loader now reads through a metered view of the bundle (Metered in host.rs), charged by file size before each read, with MAX_PROJECT_BYTES (256 MiB) for everything it loads: manifests, argument files and variable files, counted once per canister that names them. The phases keep reading the bundle itself, so a module read several times over a deploy is not charged. Tested at loading_the_project_is_held_to_its_read_budget (two canisters naming one file: the second read is what trips the budget).
Implicit archive directories bypass the filesystem-object limit. Extraction now counts files and directories alike against MAX_BUNDLE_ENTRIES, charging each directory an entry's path implies once (read off the index and the directory entries written so far). Since that walk costs a lookup per component, an entry's path is first held to MAX_ENTRY_PATH_BYTES (4096, Linux's PATH_MAX). Tested at the_directories_an_archive_implies_count_against_its_cap and an_entry_path_past_the_length_limit_is_refused.
Plugin execution lacks a store memory resource limiter. Not bounded here, and host_for's docs now say so. The runner is icp_sync_plugin::Wasmtime, which builds its own Engine and Store inside a private function; nothing on this side of its Run trait can attach a StoreLimits, and the real plugins (e.g. the certified-assets plugin) declare no memory maximum, so a static check on the component would refuse them. The fix is a store limiter in icp-sync-plugin's runner upstream, after which this crate bumps the pinned rev.
#231 and #232 are rebased on top locally and get pushed next.
The reason will be displayed to describe this comment to others. Learn more.
Can we avoid adding some of these 3rd party crates?
For the ones that we cannot replicate, let's have an explicit analysis of the pinned version, since this repo is particularly sensitive to supply chain vulnerabilities.
The reason will be displayed to describe this comment to others. Learn more.
Our own additions are two crates, camino and tar (flate2, tempfile and async-trait were already in the tree). The other ~125 new packages come through the four icp-cli crates: ~60 are wasmtime/cranelift via icp-sync-plugin (the CLI runs sync steps, asset upload included, as wasm plugins), ~10 are cap-std via icp-project's confined filesystem, ~10 are ic-cdk/ledger types via icp-canister-interfaces. Those can't be dropped without reimplementing the CLI's sync runtime. For the rest, I can add a cargo-vet/cargo-deny policy in CI or a per-version review of the pinned set — say which.
…ted input
The first of three changes that bring `icp deploy` to the local MCP
server through the icp CLI's own project library. This one is the
library's `Host` for a bundle — the `.tar.gz` that `icp project bundle`
writes — and nothing yet drives it.
A bundle is handed over by an agent, so the host takes it for exactly
what a bundle is and no more:
- `extract_bundle` unpacks it into a scratch directory under a
compressed-size limit (256 MiB) and a decompressed-stream limit
(1 GiB), refusing symlinks and other non-file entries, and anything
under `.icp/`, the state a deploy seeds itself.
- `BundleFs` is the `FileSystem` the library reads the project through:
backed by the index of what extraction wrote, so a path is normalized
against the bundle root (`..` clamped, absolute re-anchored) and looked
up there. A path the archive did not carry is "not in the bundle",
whatever exists on disk at that spelling. Writes land only in scratch
directories the filesystem created.
- `IcMainnet` is the network seam: the `ic` network with the pinned
mainnet root key, and nothing else.
- `BundledWasm` serves local wasm sources from the index, sha256
verified; `NoScripts` runs pre-built steps and refuses script ones.
- `check_bundle` validates the whole bundle before a deploy would run:
one pre-built step per canister naming a wasm in the archive that
hashes to its checksum, every sync plugin in the archive, no scripts,
an environment on `ic`.
The crate is `publish = false` by construction: `icp-project` and its
siblings are git dependencies, which crates.io refuses, so this cannot
live in `imcp2-core`. The four icp-cli crates are pinned to one rev.
`icp-sync-plugin` brings wasmtime into the workspace. The manifest
carries the full dependency list so the lockfile lands once; a few deps
(rmcp, base64) are used only by the tools that follow.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…store the crate version
Four review findings on the host:
- The network seam accepted any connected network claiming the mainnet
root key, so a manifest could point the user's signed calls at an
endpoint of its choosing. Only the reserved `ic` network is served
now: that name, the mainnet endpoint and the mainnet root key, which
is what the loader supplies and refuses to let a manifest redeclare.
- A canister directory spelled absolutely (`/etc`) resolved inside the
bundle for every reader that goes through `BundleFs`, but the
sync-plugin runtime takes it as a host path and makes a directory
outside the project its own sandbox root. `check_bundle` now holds
each canister directory to the bundle root by spelling.
- The `.icp/` guard at extraction tested the archive path's first
component, which `./.icp/...` sidesteps. It tests the normalized path.
- The root crate had slipped back to 0.6.0 while the others are at
0.6.1, which the release guard refuses. Restored, lockfile included.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four more review findings on the host:
- Directory lookups scanned the whole index per call, which a recursive
glob over a bundle of many files turned quadratic. A path set orders
by component, so a directory's descendants sit right after it: `is_dir`
and `read_dir` now take one range lookup and read while the prefix
holds. An archive is also capped at 250,000 entries, since the byte
budget alone admits millions of empty files.
- Canister names that differ only in letter case passed, and the
library's artifact store keys build output by name on disk, where a
case-insensitive volume would hand the last-built module to both.
`check_bundle` refuses them.
- A gzip-compressed module hashed correctly however much it expanded to,
and the library gunzips it in memory, unbounded, to read its Candid
interface. `check_bundle` streams it through a bounded decoder first,
holding it to 100 MiB expanded, the IC's own ceiling on a module.
- The `.icp/` guard at extraction compared case-sensitively, which
`.ICP/` sidesteps on a case-insensitive disk. Compared without case.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eate
Two more costs a bundle could run up past the budgets that were there, and
one it still can, named for what it is.
The project loader reads an argument or variable file into every canister
that names it and keeps each copy, so the extraction budget bounded none
of it: fifty canisters naming one file of 100 MiB would hold 5 GiB. The
loader now reads through a metered view of the bundle, charged by file
size before each read, with 256 MiB for everything it loads; the phases
keep reading the bundle itself, where a module is read as often as the
deploy needs it.
The entry cap counted archive records, but `unpack_in` creates the missing
parents of every entry, so a few files at the bottom of long, distinct
directory chains cost inodes the count never saw. Extraction now counts
files and directories alike, charging each directory an entry's path
implies once, read off the index and the directory entries written so far.
Since that walk costs a lookup per component, an entry's path is first
held to Linux's PATH_MAX, past which nothing could be created anyway.
What stays unbounded is a sync plugin's memory: the runner in
icp-sync-plugin bounds compute time and stack and builds its own engine
and store, so a store limiter belongs there. The host's docs say so, so
the gap is not mistaken for a bound.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The index-backed filesystem went: the confinement it provided is now the
library's own. dfinity/icp-cli#866 (issue #865) adds `files::Confined`,
the host filesystem held to one directory: every path is resolved
against the root before the disk sees it, and one that comes to rest
outside — by climbing, by being absolute elsewhere, or through a symlink
inside pointing out — is refused. The loader resolves every path a
manifest names through the filesystem it is given, so that one seam
holds all of them, canister directories included, which is also why the
spelling check on canister directories went: a directory outside the
root is not found, and the project does not load.
What stays here is what the library cannot know about an archive: the
bounds on its size, its decompressed size, its files and directories
and their paths, the refusal of symlinks, device nodes and `.icp`, the
metered view the loader reads through, and the checks on the bundle's
shape before any phase runs.
The four icp-cli crates are pinned to that branch's head until it is on
main.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…aths to it
The check read, hashed and gunzipped a module once per canister naming
it, and nothing summed the expansions: a manifest of ten thousand
canisters on one gzip module expanding to the 100 MiB limit had it
decompress a terabyte before refusing anything, and ten thousand
kilobyte-sized gzip files did the same without repeating a name. Now
each file is read, hashed and expanded once, by where it lies on disk
(`Verified`), and the expansions are summed under
`MAX_WASM_BYTES_IN_ALL`, the extraction budget again: compressing a
module buys nothing against it.
The check also said nothing about what a sync step declares under
`dirs:` and `files:`. The runtime resolves each against the canister
directory, confined to the project, and reads every `files:` entry into
a string of its own before the plugin runs — so a missing file, or a
path outside the bundle, failed at sync, after the canisters had been
created and paid for, and a 100 MiB file declared a hundred times asked
for 10 GiB. `check_declared` now holds each entry to the runtime's rule
(relative, within the root, present, a directory where `dirs:` says so)
and the files, counted as often as declared, to `MAX_PLUGIN_INPUT_BYTES`
per step. Which of the two settings a plugin's interface accepts stays
the runtime's to tell.
Two regressions for what the move to `Confined` already fixed: the
parents a directory entry implies are counted once, and an empty
directory entry is a directory of the bundle.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
dfinity/icp-cli#868 completes the rule #866 began, split three ways
(#863): the user's own project may link wherever it likes, `icp project
bundle` writes an archive of regular files and directories alone, and a
consumer of a bundle it does not trust refuses any other entry on the
way in. `files::Confined` is accordingly held to the root by the
spelling of each path alone — it no longer canonicalizes the root or
follows symlinks to see where a path comes to rest — and `Confined::new`
is infallible.
This crate is that consumer: extraction already refuses a symlink, a
device node and anything else that is not a regular file or directory,
so the lexical check is complete here. The pin moves to the head of the
stack, the docs say why the extractor's refusal is what the confinement
relies on, and the one call site follows the signature.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…m upstream
The rule for a project someone else handed over now lives in icp-project
(dfinity/icp-cli#871, `icp_project::untrusted`, what the CLI's
`--untrusted` flag applies), so the host composes it as the CLI does
instead of assembling its own from the seams: the project is loaded
through `Confined::refusing_links`, which refuses a path through a
symlink anywhere under the root; no recipe is fetched (`NoRecipes`); and
the loaded project is vetted (`Vetted`) before it is handed out, so a
build step that is not a pre-built module from a file in the project, or
a sync step that is not a plugin from one, fails the load.
With the loader the one gate on the project's shape, the hand-rolled
`NoRecipes`, the `Build` backstop and the script checks in `check_bundle`
go; the builder is the library's own, and the syncer takes a runner that
spawns nothing, since no script step reaches it. Extraction still
refuses a symlink on the way in, and the filesystem now refuses one
again should it appear under the root later.
Pins the four icp-cli crates to the head of #871. Two tests: the load
refuses a script step, a remote module and a recipe, naming each; and
the bundle filesystem refuses a link made after extraction.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t runs
dfinity/icp-cli#871 has moved its line: between reading and running,
rather than around the project directory. Reading is no longer
restricted — the loader reads argument files, manifests and recipes
from wherever the manifest points, through the ordinary filesystem —
and what runs is held to the list: each build step a pre-built module,
each sync step a plugin, and the file each names within the project,
judged by where it really is once every link is followed and every `..`
applied. `Confined::refusing_links` and `NoRecipes` are gone from
upstream, `Vetted::new` takes the filesystem the paths resolve through,
and `--untrusted` sits on `build`, `deploy` and `sync` rather than on
every command.
The host follows. The bundle is still read through `Confined` — a bundle
has no business outside its own directory, and the vet resolves each
module through the same seam — but the confinement is this crate's
choice now, not the rule's. No recipe is fetched, by this crate's rule
rather than the library's: the CLI would render one and vet the steps
it produces, a bundle has none, so the resolver that refuses them is
back here, naming the recipe. The refusal wording follows upstream: a
step is "not a pre-built module", not "from a file in the project", and
a module linked out of the root is refused for where it resolves, not
for passing through a link.
Pins the four icp-cli crates to the head of #871. The test that read a
link made after extraction as a filesystem refusal now loads a project
through one: a link out fails the load naming where it leads, a link
within is let be, a dangling one is refused. The read-budget test's
archive carries the module its manifest names, as the vet now asks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
dfinity/icp-cli#871 now asks, after resolving where a module or plugin
really is, whether a regular file is there, since a directory cannot be
read as a module and a pipe is read from whoever writes to it. The vet
asks the filesystem it is given, and a path that does not resolve is now
refused as resolving "to anything" rather than "to a file".
The host follows, and resolves the extraction root once before the
filesystem is held to it. The vet hands the filesystem a resolved path,
and `Confined` holds every path to the root by spelling alone, so a root
named through a link — macOS keeps its temporary directories under
`/var`, a link to `/private/var` — would put every resolved path outside
it and refuse every bundle. `dunce` resolves it, as icp-project's own
host filesystem does, keeping a Windows path free of the `\\?\` prefix.
Pins the four icp-cli crates to the head of #871. Two tests: a directory
of the bundle named as a module fails the load naming what was found,
and a root named through a link is the directory it leads to.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…handle
The pin moves from the #866 -> #871 stack to #873, the same `--untrusted`
feature with `files::Confined` built on cap-std. What changes for the
host:
The confined filesystem opens the root once as a directory handle and
makes every read beneath it, so the operating system refuses a path that
leaves the root; on top of that a path through a symlink is refused
wherever it leads, within the root or without. The vet asks that
filesystem whether what a step names is a regular file, so a module
named through a link now fails the load as not a regular file of the
project, where before one resolving within the root was let be. The
root is matched as spelled, so the extraction directory is no longer
canonicalized and `dunce` goes; a root named through a link holds the
project as it is.
`NoRecipes` is the library's own now, and so is holding a plugin's
`dirs:` and `files:` to the project at the load: this crate's resolver
goes, and its declared-paths check keeps only the byte budget the
runtime's reads are held to. The tests read the new refusals, and the
docs name #873 on #868.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
dfinity/icp-cli#873 is merged (8f98455, on #868's merge e097f7f), as one
squash of the two heads this crate followed. The code is the same as the
head it was pinned at; the merge adds a doc note on `untrusted::vet` and
`Vetted::new` that the filesystem handed in must be the one that confines
the project, which the host's composition already satisfies. The pin
moves to main, and the manifest and docs stop saying it awaits that.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Untrusted plugins can exhaust MCP server memory because the runner lacks hard guest/store memory limits, and existing archive, module-expansion, and file budgets do not constrain guest allocations.
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
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
First of three stacked PRs that bring
icp deployto the local MCP server through the icp CLI's own project library (icp-project, the code behindicp deploy). This one adds the crate and the library'sHostfor a bundle, the.tar.gzthaticp project bundlewrites. Nothing drives it yet; the pipeline is #231 in the stack and the MCP tools #232.The bundle is untrusted input. A bundle is handed over by an agent, so the host takes it for exactly what a bundle is and no more:
extract_bundleunpacks it into a scratch directory under a compressed-size limit (256 MiB) and a decompressed-stream limit (1 GiB), refusing symlinks and other non-file entries, more than 250,000 files and directories (counting the parents an entry's path implies), entry paths over 4096 bytes, and anything under.icp/in any letter case, the id and artifact state a deploy seeds itself.FileSystemthe library reads the project through is the library's ownfiles::Confined, the host filesystem held to the extraction directory: the root is opened once as a directory handle and every read is made beneath it, so the operating system refuses a path that leaves the root, by climbing, by being absolute elsewhere or by a symlink; on top of that, a path through a symlink is refused wherever it leads. The loader reads every path a manifest names through that one seam, so a manifest can name only what the archive brought with it. The project loader reads through a metered view of it, with a 256 MiB budget on what it retains.icp_project::untrusted::Vetted, whaticp deploy --untrustedapplies): each build step a pre-built module, each sync step a plugin, the file each names a regular file the confined filesystem will give, and what a plugin step hands its plugin a directory or regular file there too. A script step, a module or plugin fetched from a URL, or a path that is not such a file fails the load, before any phase. No recipe is fetched (untrusted::NoRecipes, the library's own): a recipe is read or fetched while the project loads, so a project naming one fails to load, naming the recipe.IcMainnetis the network seam: the reservedicnetwork as the loader supplies it (that name, the mainnet endpoint, the mainnet root key), nothing else. A manifest may declare a network of any other name that claims the mainnet root key over an endpoint of its own; refused.BundledWasmserves local wasm sources from the bundle, sha256 verified.NoScriptsis the script runner the syncer is composed with; it spawns nothing, since the vetted loader admits no script step.check_bundlevalidates the whole bundle before a deploy would run: one pre-built step per canister naming a wasm in the archive that hashes to its recorded checksum (and, if gzip-compressed, expands to no more than 100 MiB, since the library gunzips it in memory unbounded), canister names that differ by more than letter case (the artifact store keys build output by name on disk), every sync plugin in the archive with the bytes itsfiles:declare held to a sum, an environment onic. A canister directory outside the root needs no check of its own any more: the confined loader does not find it, and the project does not load.Relationship to icp-cli. The confinement used to live here, as an index-backed filesystem of this crate's own. The rule is one, split three ways upstream (dfinity/icp-cli#863, issue #865): the user's own project may run, fetch and link whatever it likes;
icp project bundlewrites an archive of regular files and directories with every step spelled out (#868); and a consumer of a bundle it does not trust loads it as untrusted (#873, on #868:files::Confinedon cap-std,untrusted::NoRecipesanduntrusted::Vetted). This PR pins the four icp-cli crates to icp-cli's main at the merge of #873 (8f98455, the squash of #873 on #868's merge). TheCargo.tomlcomment says so.Why a new crate, and why unpublished.
icp-projectand its siblings arepublish = falseupstream, so they can only be git dependencies, and crates.io refuses any crate that carries one.imcp2-coreandimcp2publish there, so this is its ownpublish = falsecrate, composed intoimcp2-localalone (in #232). The four icp-cli crates are pinned to one rev and must be bumped together.icp-sync-pluginbrings wasmtime into the workspace, so builds are slower. The manifest carries the crate's full dependency list so the lockfile lands once;rmcpandbase64are used only from #232.Most of
host.rsexists because icp-project ships no host for a bundle. dfinity/icp-cli#863 asks for one upstream, with filesystem and browser backings, so this could later shrink to the MCP glue.Related issues
Changes
crates/imcp2-deploy(new,publish = false, version 0.6.1 like the other crates):Cargo.toml,src/lib.rs(crate doc,pub mod host),src/host.rs.Cargo.tomladds the member;Cargo.lockgains the icp-cli crates and their tree (plus semver-compatible patch bumps ofanyhow,toml,wasm-encoder/wasmparser/wast, and wasmtime 49.0.5 from the newer icp-cli pin).Testing
cargo build --locked --workspace --all-targetscargo test --locked --workspace --all-targets(the 23 in the new crate are new)cargo fmt --all -- --check/cargo clippy --locked --workspace --all-targets(clean)npm test --prefix monitoring/mcp-status(dashboard unchanged)The tests are offline. The network seam: only the reserved
icis served, and it resolves to the pinned root key; a managed network, a fetched key, another name with the mainnet key, and the mainnet key over a loopback endpoint are refused. Extraction: no manifest, not an archive, oversize compressed, oversize decompressed, a symlink entry, an entry under.icp/spelled plainly, as./.icp/and as.ICP/, too many files and directories (implied parents counted), an entry path past the length limit. A gzipped module is held to its limit. The bundle is served through the confined filesystem by any spelling inside the root, and absolute or climbing paths are refused as outside it; the filesystem's own spelling and symlink cases are tested upstream in icp-cli#873. The load refuses a script step, a remote module and a recipe, naming each; a module named through a symlink made after extraction, out of the root, within it or dangling, fails the load as not a regular file of the project, and a read through the link is refused naming it; a directory named as a module is refused; a root named through a link holds the project. A sync step's declareddirs:andfiles:are held to the bundle at the load and to a byte sum at the check. The loader's read budget is tested with two canisters naming one argument file.Checklist
🤖 Generated with Claude Code