Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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, 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. A recipe is refused, wherever it is from. It expects to be run from the directory that holds `icp.yaml`, which it takes as the project root: it does not look in the directories above, and running from a subdirectory fails. `--project-root-override` names another directory. Anything else is refused before the command acts on it, naming what was found. The `.icp` directory is the tool's own state and is neither confined nor checked, so delete one that came with the project before running.
* 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
Expand Down
33 changes: 23 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ bytes = "1.11"
camino = { version = "1.1.9", features = ["serde1"] }
cargo-generate = "0.23.7"
camino-tempfile = "1"
cap-std = "4"
candid = "0.10.19"
candid_parser = "0.3.0"
clap = { version = "4.5.3", features = ["derive", "env", "string", "unstable-ext"] }
Expand Down
77 changes: 67 additions & 10 deletions crates/icp-app/src/context/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ 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,
canister::recipe::Resolve,
files::{Confined, FileSystem, HostFileSystem, RootError},
host::Host,
manifest::{self, ProjectRootLocate as _},
store_id, untrusted,
};

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

#[snafu(display(
"an untrusted project is run from the directory that holds its icp.yaml, or named with \
--project-root-override"
))]
UntrustedRoot {
source: manifest::ProjectRootLocateError,
},

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

/// 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,11 +97,34 @@ 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));

// An untrusted project is the directory the command stands in, or the
// one it was pointed at, and nothing is searched for. Finding a root
// otherwise means reading manifests above and following the dependencies
// they name, before there is a root to hold those reads to, and it lets
// a project above adopt this one and widen what it may read.
let project_root_override = if untrusted {
Some(project_root_override.unwrap_or_else(|| cwd.clone()))
} else {
project_root_override
};
let project_root_locate = Arc::new(manifest::ProjectRootLocateImpl::new(
cwd,
project_root_override,
));

// An untrusted project is confined to its directory in what it reads.
let confined = if untrusted {
let root = project_root_locate.locate().context(UntrustedRootSnafu)?;
Some(Confined::new(&root).context(ConfineSnafu)?)
Comment thread
raymondk marked this conversation as resolved.
Resolved
} else {
None
};

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

Expand All @@ -100,14 +145,23 @@ pub fn initialize(
pkg_cache.clone(),
));

// Recipes
let recipe = Arc::new(RecipeFetcher {
http_client,
pkg_cache,
});
// 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),
};

// The project's files come from this machine.
let files = Arc::new(icp_project::files::HostFileSystem);
// Recipes: fetched for the user's own project, refused for an untrusted
// one, which states its steps itself.
let recipe: Arc<dyn Resolve> = if untrusted {
Arc::new(untrusted::NoRecipes)
} else {
Arc::new(RecipeFetcher {
http_client,
pkg_cache,
})
};

// Canister builder
let builder = Arc::new(Builder::new(wasm.clone(), files.clone()));
Expand All @@ -125,8 +179,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 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
18 changes: 18 additions & 0 deletions crates/icp-cli/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,24 @@ 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
Comment thread
raymondk marked this conversation as resolved.
Resolved
/// plugin, and it uses no recipe. Anything else is refused. Run it from
/// the directory that holds `icp.yaml`: that directory is the project
/// root, and no root is looked for in the directories above it. Name
/// another directory with `--project-root-override`. The `.icp`
/// directory is the tool's own state and is neither confined nor
/// checked: delete one that came with the project before running
#[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