Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
dcec008
feat(project): a filesystem confined to the project root, for a proje…
raymondk Nov 4, 2026
6f5b535
fix(project): hold Confined to the root by spelling alone
raymondk Nov 4, 2026
397bef0
docs(project): say what Confined holds in fewer words
raymondk Nov 10, 2026
659b354
feat(project): Confined takes an absolute root and refuses every symlink
raymondk Nov 10, 2026
cbf9e07
docs(project): note that a path Confined refuses can be reported as m…
raymondk Nov 10, 2026
8a1df3d
fix(project): Confined refuses a path that mentions a symlink, before…
raymondk Nov 10, 2026
44a10f7
fix(bundle): hold a bundle to regular files and directories
raymondk Nov 4, 2026
0ec22ec
fix(bundle): refuse the output, a non-UTF-8 name and a looping link b…
raymondk Nov 4, 2026
777ddb3
docs(bundle): keep the Intel macOS chore out of Experimental, drop a …
raymondk Nov 5, 2026
624dbca
refactor(bundle): resolve each directory once on the walk, and list n…
raymondk Nov 10, 2026
0e1dc02
fix(bundle): resolve the output once it exists, for the walk to refuse
raymondk Nov 10, 2026
5c7c8a4
fix(bundle): hold a files: entry to what the walk holds, and resolve …
raymondk Nov 10, 2026
58af0d8
feat(project): an untrusted project is held to the shape a bundle has
raymondk Nov 5, 2026
c490cb0
refactor(project): say what an untrusted step may be, not what it may…
raymondk Nov 5, 2026
4953f0d
fix(untrusted): read every module through the confined filesystem, an…
raymondk Nov 5, 2026
e3c2b23
refactor(cli): put --untrusted on build, deploy and sync alone
raymondk Nov 6, 2026
77f4633
docs(untrusted): say what an untrusted project may have, not what it …
raymondk Nov 6, 2026
f3ba100
refactor(untrusted): hold what a project runs, not what it reads
raymondk Nov 6, 2026
c09004b
test(untrusted): a chain of links, and a linked directory on the way,…
raymondk Nov 6, 2026
df0ca77
fix(untrusted): what a step names must be a regular file
raymondk Nov 9, 2026
602c8a3
fix(untrusted): ask what a step names is of the path as spelled
raymondk Nov 10, 2026
85852bf
refactor(untrusted): one match per step kind, and less said twice
raymondk Nov 10, 2026
dd69ace
feat(untrusted): read an untrusted project through a filesystem confi…
raymondk Nov 10, 2026
a67e243
feat(untrusted): hold what the CLI keeps under .icp to the confined f…
raymondk Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ air-gapped signing, extensions

# Unreleased

* feat: `--untrusted`, on `build`, `deploy` and `sync`, holds a project someone else handed over to what it may read and run. Every file the project reads — manifests, argument and environment files, local recipe templates, modules — must be under the project root and reached through no symlink. Each build step must be a pre-built module and each sync step a plugin, taken from a regular file in the project; the steps a recipe renders to are held to the same list. Anything else is refused before the command acts on it, naming what was found. What the CLI keeps under `.icp` is held to the same rule, so a symlink planted there is refused rather than written through.
* chore: macOS on Intel (`x86_64-apple-darwin`) is no longer a release target. Releases ship no Intel macOS archive, the shell installer refuses that platform, and npm no longer publishes `@icp-sdk/icp-cli-darwin-x64`, so installing `@icp-sdk/icp-cli` on an Intel Mac warns of an unsupported platform. Homebrew dropped Intel macOS in September 2026, and GitHub is retiring its Intel macOS runners. v1.8.0 is the last release with an Intel macOS build.

## Experimental

* fix(bundle): `icp project bundle` holds only regular files and directories. A symlink inside a plugin's `files:` or `dirs:` directory used to be recorded as a link with its target unchecked, so one pointing outside the project reached that file on whatever machine unpacked the bundle. A link to somewhere inside the project is now copied as what it points at; one that leads outside the project, dangles, or leads back to a directory being copied fails the bundle, naming the link and where it leads. The same holds for a `files:` entry that is itself a symlink.

# v1.8.0

* fix: `icp sync` no longer needs controller rights. Its check that each canister is running used `canister_status`, which only a controller or a listed status viewer may read, so an identity the canister merely authorizes (a developer preparing an SNS-owned frontend for a commit proposal, say) was refused before anything was uploaded. It now probes with a query, as `icp deploy` already does after starting a canister. A stopped canister is still refused with the same advice, after the probe's 30-second wait rather than at once.
Expand Down
62 changes: 54 additions & 8 deletions crates/icp-app/src/context/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ use icp_project::store_artifact::ArtifactStore;
use std::time::Duration;

use crate::{agent, identity, identity::PasswordFunc};
use icp_project::{Lazy, ProjectLoadImpl, host::Host, manifest, store_id};
use icp_project::{
Lazy, ProjectLoad, ProjectLoadImpl,
files::{Confined, FileSystem, HostFileSystem, RootNotAbsolute},
host::Host,
manifest::{self, ProjectRootLocate as _},
store_id, untrusted,
};

#[derive(Debug, Snafu)]
pub enum ContextInitError {
Expand All @@ -36,13 +42,25 @@ pub enum ContextInitError {
PackageCache {
source: icp_project::fs::lock::LockError,
},

#[snafu(display("failed to locate the project to treat as untrusted"))]
UntrustedRoot {
source: manifest::ProjectRootLocateError,
},

#[snafu(display("failed to confine the untrusted project to its directory"))]
Confine { source: RootNotAbsolute },
}

/// With `untrusted`, the project is read through a filesystem confined to
/// its root and vetted by [`icp_project::untrusted`] before any command acts
/// on it. The root is located up front for that, so there has to be a project.
pub fn initialize(
project_root_override: Option<PathBuf>,
debug: bool,
password_func: PasswordFunc,
pem_session_duration: Option<Duration>,
untrusted: bool,
) -> Result<Context, ContextInitError> {
// Setup global directory structure
let dirs = Arc::new(Directories::new().context(DirectoriesSnafu)?);
Expand Down Expand Up @@ -75,16 +93,35 @@ pub fn initialize(
let cwd: PathBuf =
PathBuf::try_from(current_dir().context(CwdSnafu)?).context(Utf8PathSnafu)?;

// A relative override is taken from the working directory: the
// filesystem an untrusted project is confined to matches every path
// against the root as spelled, so the root has to be a whole path.
let project_root_override = project_root_override.map(|dir| cwd.join(dir));
let project_root_locate = Arc::new(manifest::ProjectRootLocateImpl::new(
cwd,
project_root_override,
));

// An untrusted project is confined to its directory: what it reads, and
// what the CLI keeps under `.icp` on its behalf.
let confined = if untrusted {
let root = project_root_locate.locate().context(UntrustedRootSnafu)?;
Some(Confined::new(&root).context(ConfineSnafu)?)
} else {
None
};

// Canister ID Store
let ids = Arc::new(store_id::AccessImpl::new(project_root_locate.clone()));
let ids = Arc::new(store_id::AccessImpl::new(
project_root_locate.clone(),
confined.clone(),
));

// Canister Artifact Store (wasm)
let artifacts = Arc::new(ArtifactStore::new(project_root_locate.clone()));
let artifacts = Arc::new(ArtifactStore::new(
project_root_locate.clone(),
confined.clone(),
));

// Prepare http client
let http_client = reqwest::Client::new();
Expand All @@ -100,15 +137,20 @@ pub fn initialize(
pkg_cache.clone(),
));

// The project's files come from this machine: the whole of it for the
// user's own project, the project directory alone for an untrusted one.
let files: Arc<dyn FileSystem> = match &confined {
Some(confined) => Arc::new(confined.clone()),
None => Arc::new(HostFileSystem),
};

// Recipes
let recipe = Arc::new(RecipeFetcher {
http_client,
pkg_cache,
files: files.clone(),
});

// The project's files come from this machine.
let files = Arc::new(icp_project::files::HostFileSystem);

// Canister builder
let builder = Arc::new(Builder::new(wasm.clone(), files.clone()));

Expand All @@ -125,8 +167,11 @@ pub fn initialize(
files: files.clone(),
};

let pload = Lazy::new(pload);
let pload = Arc::new(pload);
let pload: Arc<dyn ProjectLoad> = if untrusted {
Arc::new(Lazy::new(untrusted::Vetted::new(pload, files.clone())))
} else {
Arc::new(Lazy::new(pload))
};

// Telemetry data bag (written by subsystems, read at session finish)
let telemetry_data = Arc::new(crate::telemetry_data::TelemetryData::default());
Expand All @@ -152,6 +197,7 @@ pub fn initialize(
// Network accessor
let netaccess = Arc::new(crate::network::Accessor {
project_root_locate: project_root_locate.clone(),
confined,
descriptors: dirs.port_descriptor(),
agent: agent_creator.clone(),
});
Expand Down
18 changes: 16 additions & 2 deletions crates/icp-app/src/network/accessor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use candid::Principal;
use snafu::{ResultExt, Snafu};
use url::Url;

use icp_project::files::{Confined, FsError};
use icp_project::manifest::{ProjectRootLocate, ProjectRootLocateError};
use icp_project::network::{
Access, AccessError, CollectFriendlyDomains, Configuration, NetworkAccess, NetworkUrls,
Expand All @@ -26,6 +27,9 @@ use crate::network::{
pub enum LocateNetworkDirectoryError {
#[snafu(display("failed to find project root"))]
ProjectRootLocate { source: ProjectRootLocateError },

#[snafu(display("network '{network}' keeps its state where an untrusted project may not"))]
Confine { source: FsError, network: String },
}

/// Where a network keeps its on-disk state.
Expand Down Expand Up @@ -57,6 +61,10 @@ pub struct Accessor {
// Project root
pub project_root_locate: Arc<dyn ProjectRootLocate>,

// What an untrusted project is confined to, which its network
// directories are then held to as well
pub confined: Option<Confined>,

// Port descriptors dir
pub descriptors: PathBuf,

Expand All @@ -74,14 +82,20 @@ impl Accessor {
.project_root_locate
.locate()
.context(ProjectRootLocateSnafu)?;
Ok(NetworkDirectory::new(
let nd = NetworkDirectory::new(
&network.name,
&dir.join(ICP_BASE)
.join(CACHE_DIR)
.join("networks")
.join(&network.name),
&self.descriptors,
))
);
if let Some(confined) = &self.confined {
nd.held_by(confined).context(ConfineSnafu {
network: &network.name,
})?;
}
Ok(nd)
}
}

Expand Down
15 changes: 15 additions & 0 deletions crates/icp-app/src/network/directory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,21 @@ impl NetworkDirectory {
Ok(())
}

/// Refuse the files this directory is read through — its lock and its
/// descriptor — unless `confined`, the filesystem an untrusted project is
/// held to, would give them.
pub(crate) fn held_by(
&self,
confined: &icp_project::files::Confined,
) -> Result<(), icp_project::files::FsError> {
let paths = NetworkRootPaths {
network_root: self.network_root.clone(),
};
confined.resolve(&paths.lock_file())?;
confined.resolve(&paths.network_descriptor_path())?;
Comment on lines +181 to +182
Ok(())
}

/// Locked directory access for the local network root.
pub fn root(&self) -> Result<DirectoryStructureLock<NetworkRootPaths>, LockError> {
DirectoryStructureLock::open_or_create(NetworkRootPaths {
Expand Down
9 changes: 6 additions & 3 deletions crates/icp-app/src/recipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::package::{
read_cached_uri_recipe,
};
use icp_project::{
fs::read,
files::{FileSystem, FsError},
manifest::recipe::{Recipe, RecipeType},
prelude::*,
};
Expand All @@ -30,6 +30,8 @@ pub struct RecipeFetcher {
pub http_client: reqwest::Client,
/// Package cache for caching downloaded recipe templates
pub pkg_cache: std::sync::Arc<PackageCache>,
/// The project's files, which a local template is read through
pub files: std::sync::Arc<dyn FileSystem>,
}

/// The result of the fetch stage, as this resolver sees it.
Expand Down Expand Up @@ -76,7 +78,7 @@ enum TemplateSource {
#[derive(Debug, Snafu)]
pub enum RecipeFetchError {
#[snafu(display("failed to read local recipe template file"))]
ReadFile { source: icp_project::fs::IoError },
ReadFile { source: FsError },

#[snafu(display("failed to decode UTF-8 string"))]
DecodeUtf8 { source: FromUtf8Error },
Expand Down Expand Up @@ -133,7 +135,7 @@ impl RecipeFetcher {
// Retrieve the template, using cache for remote/registry sources
let (tmpl, should_cache) = match &tmpl_source {
TemplateSource::LocalPath(path) => {
let bytes = read(path).context(ReadFileSnafu)?;
let bytes = self.files.read(path).await.context(ReadFileSnafu)?;
(parse_bytes_to_string(bytes)?, false)
}

Expand Down Expand Up @@ -360,6 +362,7 @@ mod tests {
RecipeFetcher {
http_client: reqwest::Client::new(),
pkg_cache: std::sync::Arc::new(crate::package::open(cache_dir.to_owned()).unwrap()),
files: std::sync::Arc::new(icp_project::files::HostFileSystem),
}
}

Expand Down
5 changes: 4 additions & 1 deletion crates/icp-cli/src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use tracing::info;
use icp_project::operations::build::build_many;

use crate::{
options::{EnvironmentOpt, arg_struct_change_help},
options::{EnvironmentOpt, UntrustedOpt, arg_struct_change_help},
render::rendered,
};

Expand All @@ -22,6 +22,9 @@ pub(crate) struct BuildArgs {

#[command(flatten)]
pub(crate) environment: BuildEnvironmentOpt,

#[command(flatten)]
pub(crate) untrusted: UntrustedOpt,
}

arg_struct_change_help!(
Expand Down
5 changes: 4 additions & 1 deletion crates/icp-cli/src/commands/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use tracing::info;
use crate::options::EnvironmentOpt;
use crate::{
commands::{args::ArgsOpt, canister::create},
options::{IdentityOpt, arg_struct_change_help},
options::{IdentityOpt, UntrustedOpt, arg_struct_change_help},
render::rendered,
};

Expand Down Expand Up @@ -97,6 +97,9 @@ pub(crate) struct DeployArgs {
/// `upgrade_args` in the manifest.
#[command(flatten)]
pub(crate) args_opt: ArgsOpt,

#[command(flatten)]
pub(crate) untrusted: UntrustedOpt,
}

arg_struct_change_help!(
Expand Down
5 changes: 4 additions & 1 deletion crates/icp-cli/src/commands/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use tracing::info;
use icp_project::operations::{readiness::wait_until_serving_queries, sync::sync_many};

use crate::{
options::{EnvironmentOpt, IdentityOpt},
options::{EnvironmentOpt, IdentityOpt, UntrustedOpt},
render::rendered,
};

Expand All @@ -32,6 +32,9 @@ pub(crate) struct SyncArgs {

#[command(flatten)]
pub(crate) identity: IdentityOpt,

#[command(flatten)]
pub(crate) untrusted: UntrustedOpt,
}

pub(crate) async fn exec(ctx: &Context, args: &SyncArgs) -> Result<(), anyhow::Error> {
Expand Down
1 change: 1 addition & 0 deletions crates/icp-cli/src/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ fn context() -> Option<&'static Context> {
false,
Arc::new(|| Err("cannot prompt while completing".to_string())),
None,
false,
Comment thread
raymondk marked this conversation as resolved.
Resolved
)
.ok()
})
Expand Down
16 changes: 16 additions & 0 deletions crates/icp-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ async fn run() -> Result<(), Error> {
cli.debug,
password_func,
pem_session_duration,
command.untrusted(),
)?;

// Extension commands are left out of telemetry: telemetry parses against
Expand Down Expand Up @@ -266,6 +267,21 @@ enum Invocation {
Extension(icp_app::extension::Installed, ArgMatches),
}

impl Invocation {
/// Whether the command was asked to treat the project as untrusted. The
/// flag belongs to the commands that act on what a project says — build,
/// deploy, sync — but it is the context every command runs in that
/// applies it, so it is read off the command before that is composed.
fn untrusted(&self) -> bool {
match self {
Invocation::BuiltIn(Command::Build(args)) => args.untrusted.untrusted,
Invocation::BuiltIn(Command::Deploy(args)) => args.untrusted.untrusted,
Invocation::BuiltIn(Command::Sync(args)) => args.untrusted.untrusted,
Invocation::BuiltIn(_) | Invocation::Extension(..) => false,
}
}
}

/// Dispatch the command to its handler.
async fn dispatch(ctx: &icp_app::context::Context, command: Command) -> Result<(), Error> {
match command {
Expand Down
13 changes: 13 additions & 0 deletions crates/icp-cli/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ impl From<IdentityOpt> for IdentitySelection {
}
}

/// `--untrusted`, for a command that acts on what a project says: build,
/// deploy, sync. A deploy service handed a project by an agent is what asks
/// for it, and those are the commands it runs.
#[derive(Args, Clone, Debug, Default)]
pub(crate) struct UntrustedOpt {
/// Hold the project to what one someone else handed over may do: every
/// file it reads is under the project root and reached through no
/// symlink, each build step is a pre-built module and each sync step a
/// plugin. Anything else is refused
#[arg(long)]
pub(crate) untrusted: bool,
}

#[derive(Args, Clone, Debug, Default)]
#[clap(group(ArgGroup::new("environment-select").multiple(false)))]
pub(crate) struct EnvironmentOpt {
Expand Down
Loading
Loading