Repository navigation
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Crafted bundles can execute arbitrary local scripts, exfiltrate files outside the extraction directory, and exhaust temporary storage because execution restrictions, path containment, and extraction quotas are not enforced.
4 open issues
What changed in this PR
Adds icp_deploy_bundle to the local MCP server, deploying project bundles through the icp CLI’s project library as the signed-in user. The deployment code lives in a separate, unpublished crate to keep git dependencies out of the crates.io-published components.
Changes:
- Adds bundle handling, deployment orchestration, signed canister calls, and structured results with logs and canister IDs.
- Integrates the deploy tool into local server routing, authentication, documentation, and tests.
- Adds pinned icp-cli dependencies and the Wasmtime sync-plugin runtime.
| File | Description |
|---|---|
crates/imcp2-local/src/server.rs |
Routes the deploy tool and extends server instructions and tests. |
crates/imcp2-local/src/main.rs |
Constructs the deploy tools with shared session access. |
crates/imcp2-local/README.md |
Documents deployment inputs, results, and authentication. |
crates/imcp2-local/Cargo.toml |
Adds the local deployment dependency. |
crates/imcp2-deploy/src/lib.rs |
Defines tool arguments, validation, authentication, and results. |
crates/imcp2-deploy/src/host.rs |
Implements bundle extraction and project host adapters. |
crates/imcp2-deploy/src/deploy.rs |
Orchestrates deployment, ID recovery, logging, and offline tests. |
crates/imcp2-deploy/src/calls.rs |
Implements signed calls and certified reads through ic-agent. |
crates/imcp2-deploy/Cargo.toml |
Defines the unpublished crate and pins icp-cli dependencies. |
Cargo.toml |
Registers the deployment crate in the workspace. |
Cargo.lock |
Locks the deployment dependency tree and related updates. |
Review effort: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical flaws let crafted bundles execute host commands, read outside the extraction root, and exhaust local resources, while incomplete canister ID recovery can cause duplicate deployments and repeated cycle spending.
6 open issues
Confine manifest sources to the extracted bundle root · New Archive extraction can exceed the 256 MiB size budget Untrusted archive scripts execute during build and sync Manifest file paths can escape the extracted directory Expose or document a supported cycles balance check · New Created canister IDs are lost after persistence failure
Review effort: Balanced
| otherwise. `icp_cycles_balance` reports the balance and the principal | ||
| it belongs to. Before a deploy that will create canisters, tell the user how |
…via icp-project A new non-published crate, imcp2-deploy, wraps icp-project's deploy operation behind an MCP tool. It takes a bundle (path or base64), an environment name and an optional canister id mapping, and answers with the deployment status, the resulting canister id mapping, the canisters created and a per-phase log. The crate is composed only into imcp2-local: icp-project and its siblings are git dependencies, and crates.io refuses crates that carry one, so they cannot enter imcp2-core or the hosted server. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The agent-side loop around icp_deploy_bundle, as a skill resource next to core's bundled IC skills: bundle the project with `icp project bundle`, call the tool with the environment's `<env>.ids.json` as canister_ids, write the returned mapping back into that file. Served only where the tool is, as `skill://deploy-with-mcp`; the server instructions and the README point at it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
d1d0a00 to
65f73db
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical flaws let crafted bundles execute arbitrary commands as the server user and exhaust local disk space through unbounded archive extraction.
7 open issues
Confine manifest sources to the extracted bundle root Archive extraction can exceed the 256 MiB size budget Untrusted archive scripts execute during build and sync Manifest file paths can escape the extracted directory Refresh delegated identity during long deployments · New Expose or document a supported cycles balance check Created canister IDs are lost after persistence failure
Review effort: Balanced
…ound its extraction The archive icp_deploy_bundle receives arrives in a tool call, and the agent passing it may have fetched it from anywhere; the deploy host now enforces the shape `icp project bundle` writes instead of assuming it. * Extraction writes only regular files and directories (a symlink is how an archive reaches past its directory), and the entries' sizes may not add up to more than 1 GiB — the 256 MiB archive limit bounded nothing on disk, gzip expanding by up to a thousand to one. The decompressed stream is bounded too, independently of what the headers claim. * Every path the manifest names (a wasm, an argument file, a variable file) is resolved against the extracted root, symlinks followed, and refused if it leads outside it or to anything but a regular file; the project library reads them all through its FileSystem seam, so one confined adapter covers them, and the build scratch moves under the root so a step's output stays readable through the same fence. * Script build and sync steps are refused by name before any phase runs (a sync step would otherwise be reached only after canisters were created), and the build/sync seams refuse them again as a backstop. Also: a created canister's id is merged into the returned mapping whether or not the id store could record or report it, so a retry cannot create it twice; and the one field of AgentCalls that caches the agent's principal says why (the trait's `caller` is infallible, the agent's `get_principal` is not). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The deploy docs and skill told the agent to check the balance with icp_cycles_balance before a deploy that creates canisters, but that tool belongs to the core's protocol/meta half, which the local server does not serve — the call answered "tool not found". The deploy tool group now carries a tool of that name over the same logic (re-exported from core), so the deploy loop's balance check exists on the surface it runs on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved flaws allow archive inputs to exhaust memory or block requests, archive-supplied IDs to trigger unintended canister upgrades, and expired cached identities to interrupt deployments.
5 open issues
5 resolved since last review
Review effort: Balanced
| // Seed the id store with the mapping the caller passed: from here on the | ||
| // operation sees those canisters as existing, exactly as it would see the | ||
| // ids a previous `icp deploy` recorded in the project's `.icp` directory. | ||
| for (name, id) in &request.canister_ids { |
…le for `ic` up front The deploy host no longer fences the extracted directory by canonicalizing every path against the disk. Extraction keeps the list of files it wrote, and that list is the filesystem the project library reads through: a path the manifest names is normalized against the bundle root — `..` cannot climb above it, an absolute path is re-anchored at it — and looked up in the index. Present, it is read from where extraction put it; absent, it is "not in the bundle", whatever the disk holds at that spelling. Confinement is by construction, the way the browser bundle deployer's in-memory filesystem gets it, while the disk stays the byte store because the sync-plugin runtime mounts real directories. The extraction rules (regular files and directories only, within the size budget) are what make the index trustworthy, so they stay. The bundle is also checked whole before any phase runs, in the shape `icp project bundle` writes: each canister built by exactly one pre-built wasm in the archive that hashes to its recorded checksum, each sync plugin in the archive, no script steps, and the environment on the `ic` network. Anything else is a request error that names the canister, instead of a deploy that fails at the phase that reaches it — after canisters were created. Deploying is to the Internet Computer only now: the network seam answers with the pinned mainnet key and nothing else, and the trust-on-first-use root-key fetch is gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eb` signs in as The deploy tools signed as the management identity: the default account at the server's own origin, which only this server knows. A canister it created was therefore controlled by a principal the user's icp CLI has never heard of, and funded from a cycles balance separate from the CLI's. Derive the deploy identity at `https://cli.id.ai` instead, the origin `icp identity link web` (with no `--app`) signs in at. Internet Identity gives one principal per anchor per origin, so this is the same principal the CLI deploys as on the user's machine: canisters created here are the ones `icp` lists and controls, and they draw on the same cycles. `icp_cycles_balance` follows, reporting that principal's balance. The canister lifecycle tools keep the management identity. The docs now say a deployed canister is reached through them only after adding their principal as a controller. `cycles_balance_of(agent, principal)` is split out of core's `cycles_balance` so a caller acting as another identity can read a balance, and `Identities::agent_as` is made public for the same reason. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ments may not supply Four more findings from review, each a way for a deploy's input to reach past what a bundle is: - A per-app delegation lives five minutes, and a deploy can run longer (an asset sync, several installs). The agent now signs through a `RefreshingIdentity` that swaps in a fresh delegation from the session store every ten seconds while the deploy runs, keeping the principal it started as. - An archive could carry `.icp/data/mappings/<env>.ids.json`, which the id store would read as existing canisters the caller never named. Entries under `.icp/` are refused at extraction; a bundle never has them. - A `bundle_path` naming a device or a pipe passed the size check (a zero-length `/dev/zero`) and then read without end. Only a regular file is read. - An inline bundle was decoded in full, before the limit and before the session check. The base64 text is bounded by length first, the session is checked before any decoding, and the decoded size is checked too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>


Summary
Adds
icp_deploy_bundleto the local MCP server: a tool that deploys an icp project bundle to the Internet Computer as the signed-in user, through the icp CLI's own project library. It takes a bundle (the archiveicp project bundlewrites, by path or inline), an environment name, and an optional canister-id mapping from an earlier deploy; it answers with the deployment status, the resulting canister-id mapping, the canisters created and a per-phase log. A failed run still reports every canister it created before failing, so the mapping can be passed back to resume rather than repeat. Next to it,icp_cycles_balancereports the balance and principal those deploys create canisters from.The tool drives
icp_project::operations::deploy::deployunchanged, the same entry pointicp deployuses, so creation, id wiring, settings, install/upgrade with the Candid check, and the sync steps that upload a frontend's assets all behave as the CLI does.Who deploys. Calls are signed as the user's CLI identity: their default Internet Identity account at
https://cli.id.ai, the originicp identity link websigns in at. That is the principal the icp CLI already deploys as on the user's machine, so canisters created here are the onesicplists and controls, funded from the same cycles balance. It is deliberately not the management identity the canister lifecycle tools act as; the docs say a deployed canister is reached through those tools only after adding their principal as a controller. A per-app delegation lives five minutes and a deploy can run longer, so the agent signs through aRefreshingIdentitythat swaps in a fresh delegation from the session store every ten seconds, for the same principal, until the deploy ends.The bundle is untrusted input. A bundle is a tar.gz an agent hands over, so the host treats it as exactly that and nothing more:
.icp/(the id and artifact state a deploy seeds itself), and bounds both the compressed size (256 MiB) and the decompressed stream (1 GiB). Abundle_pathmust be a regular file; an inline bundle is bounded on its base64 length before decoding, after the session check.BundleFs, a filesystem backed by the index extraction wrote: a path is resolved by normalizing it against the bundle root (..clamped, absolute re-anchored) and looking it up in the index. 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.check_bundlevalidates the whole bundle before any phase runs: every canister built by exactly one pre-built step naming a wasm in the archive that hashes to its recorded checksum, every sync plugin in the archive, no script build or sync steps (theBuildandScriptRunnerseams refuse them too), and an environment on theicnetwork. The network seam answers with the pinned mainnet root key and refuses everything else.Why a new crate, and why local-only.
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 the deploy code lives in a newpublish = falsecrate,crates/imcp2-deploy, composed intoimcp2-localalone. The four icp-cli crates are pinned to one rev and must be bumped together.icp-sync-pluginbrings wasmtime into the build, so workspace compiles are slower. Most ofhost.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 crate could shrink to the MCP glue.A skill for the agent. The server also serves
skill://deploy-with-mcpnext to core's bundled IC skills, only where the tool is. It walks through the loop from the agent's side: bundle the project withicp project bundle, callicp_deploy_bundlewith the environment's.icp/data/mappings/<environment>.ids.jsonascanister_ids, write the returned mapping back into that file so the next deploy upgrades rather than creates. The server instructions point at it.Policy note for reviewers. Core's server instructions say no tool creates, funds or deploys canisters. This tool creates canisters from the user's cycles-ledger balance (2T each by default). The local server's instructions call it out as the one exception; the wording in core is untouched and may want revisiting.
Related issues
host.rscan go upstream.Changes
crates/imcp2-deploy(new,publish = false):lib.rs:DeployBundleArgs/DeployBundleOutput, argument validation (exactly one bundle source, bounded base64, mode, principals), theIcDeployToolsrouter withicp_deploy_bundleandicp_cycles_balance,CLI_ORIGIN, structured ok/error results.identity.rs:RefreshingIdentity, theic-agentidentity a deploy signs with, refreshed from the session store while it runs.calls.rs:CanisterCallsoveric-agent. Mirrors icp-app's implementation minus proxy routing; certified reads viaread_state.host.rs: theHostfor a bundle.BundleFs(index-backed filesystem),IcMainnet(network seam,iconly, pinned root key),BundledWasm(local sources from the index, sha256 verified),NoScripts(pre-built steps only),check_bundle,extract_bundlewith its limits.static/skills/deploy-with-mcp.md+skill_document: the skill, in the registry's SKILL.md shape, compiled in like core's.deploy.rs: read the bundle source, extract to a scratch dir, load the project, check the bundle, seed the id store from the mapping,resolve_targets+deploy, read the ids back (always merged with the canisters created), render the event stream into the log.crates/imcp2-core:cycles_balance_of(agent, principal)split out ofcycles_balance, andIdentities::agent_asmade public, so the deploy tools can read a balance as an identity other than the management one.crates/imcp2-local:server.rscomposes the deploy router (list/call/get) next to the login tools, serves the deploy skill throughlist_resources/read_resourceahead of core's, and adds a DEPLOYING paragraph to the instructions;main.rswires it; README gains a "Deploying a project bundle" section.Cargo.tomladds the member;Cargo.lockgains the icp-cli crates and their tree (plus semver-compatible patch bumps ofanyhow,toml,wasm-encoder/wasmparser/wast).Testing
cargo build --locked --workspace --all-targetscargo test --locked --workspace --all-targets(71 + 8 + 10 + 206 + 27 + 21 + 1; the 27 are new)cargo fmt --all -- --check/cargo clippy --locked --workspace --all-targets(clean)npm test --prefix monitoring/mcp-status(dashboard unchanged)The new tests run the real pipeline offline: a hand-built bundle (manifest +
(module)wasm + its sha256) is extracted, loaded through icp-project, resolved to the impliciticenvironment, checked, and stopped at the create phase withno_create, exactly whereicp deploy --no-createstops. Also covered, each as a request error before any phase: a checksum mismatch, a script build or sync step, a managed network and a testnet, a manifest path the bundle does not hold, an unknown environment or canister. Extraction: no manifest, not an archive, oversize compressed and decompressed, a symlink entry, an entry under.icp/, abundle_paththat is not a regular file, an oversized inline bundle refused on length. The filesystem: a file planted under the root outside the index is unreachable, absolute and climbing paths resolve inside the bundle, build output lands in scratch and nowhere else. Plus the pinned root key, the refreshing identity's swap and same-principal check, log rendering, error chains, argument validation and the structured error shape. The local server's round-trip test expects 14 tools and checks a signed-outicp_deploy_bundlecall is refused before any file is read; its resource round-trip expects the deploy skill in the listing and reads it whole through a real client.Not covered: an actual on-chain deploy, and the Internet Identity round trip that mints a delegation at
cli.id.ai. Nothing creates a canister, installs code or runs a sync plugin, socalls.rsis exercised only by compilation. The existing PocketIC fixture has no cycles ledger or engine registry, which the create phase needs. A follow-up could add a PocketIC test usingmode: installwithno_createagainst a pre-created canister to exercise the install and metadata-read paths.Checklist
🤖 Generated with Claude Code