Skip to content

feat(project): --untrusted, with the project read beneath a cap-std directory handle - #873

Merged
raymondk merged 8 commits into
rk/bundle-symlinksfrom
rk/untrusted-cap-std
Nov 11, 2026
Merged

raymondk merged 8 commits into
rk/bundle-symlinksfrom
rk/untrusted-cap-std

Conversation

@raymondk

@raymondk raymondk commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

An alternative to the #866 → #871 stack, for comparison: the same --untrusted feature, with files::Confined built on cap-std instead of a path walk followed by a plain open. It is stacked on #868 (the bundle changes), which now sits on main by itself.

What is different from #866 / #871

  • Containment is enforced by the operating system. Confined::new opens the project root once as a cap_std::fs::Dir. Every FileSystem read, write, listing and predicate is made beneath that handle, so a path that leaves the root (by .., an absolute path or a symlink) is refused in the same step that opens it. On Linux that is one openat2 call; elsewhere cap-std walks the path a handle at a time.
  • The no-symlinks rule is kept on top. Each path component is still looked at, through the same handle, and a path through a symlink is refused wherever it leads. Error messages (is outside, passes through) are unchanged.
  • What a race can do. In feat(project): a filesystem confined to the project root, for a project from elsewhere #866 a link swapped in between the look and the read is followed anywhere. Here it is followed only to somewhere under the root.
  • canonicalize returns the path under the root as the caller spelled it, so what comes back can be asked for again (in feat(project): a filesystem confined to the project root, for a project from elsewhere #866 it is refused when the root is spelled through a symlink, such as /var on macOS).
  • Confined::new also fails if the root cannot be opened. RootNotAbsolute becomes RootError { NotAbsolute, Open }.

Also different from #871

  • .icp is not guarded. It is the tool's own state, and whoever takes a project from elsewhere is expected to discard any .icp that came with it. The id store, artifact store and network directory are as they are on main.
  • A read of anything that is not a regular file is refused before it opens, so a named pipe as an args or env file cannot hang the load.
  • What a plugin step hands its plugin is vetted: each dirs: entry must be a directory, and each files: entry a regular file or directory, in the project.
  • A recipe is refused, wherever it is from. The loader is given untrusted::NoRecipes, a Resolve that fetches nothing, so no template is read or fetched and none is rendered. In feat(project): an untrusted project is held to the shape a bundle has #871 a recipe is rendered and the steps it renders to are vetted. RecipeFetcher is as it is on main.
  • No project root is searched for. The project is the directory the command is run from, or the one --project-root-override names. In feat(project): an untrusted project is held to the shape a bundle has #871 the usual workspace climb runs first, with the host's own filesystem, and a project above that declares this one becomes the root.

What cap-std does not cover

  • The sync plugin runtime opens the plugin, and each file and directory the step hands it, by path after vet has looked. Those get the rule, not the handle's guarantee.
  • Scratch directories are made with tempfile directly under the root.

Dependency

cap-std = "4", behind icp-project's host feature. cap-primitives 4.0.3 was already in the lockfile; cap-std is the only new package. Resolving it also moved the windows-sys version of several existing packages, and one getrandom, in Cargo.lock.

Testing

On Linux: fmt, clippy, the wasm32-unknown-unknown build, check-no-host-reach.sh, icp-project tests with and without host, icp-app tests, and the untrusted, recipe and build CLI tests. macOS and Windows have only been run by CI on this PR.

A new unit test asks the directory handle directly, past the symlink look, and confirms it refuses a link out of the root and follows one that stays inside.

🤖 Generated with Claude Code

Copilot AI balanced review requested due to automatic review settings November 10, 2026 16:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical filesystem gaps in untrusted mode allow out-of-project reads and overwrites, while unchecked FIFO inputs can block execution indefinitely.

5 open issues
What changed in this PR

Adds --untrusted to build, deploy, and sync, combining project-root filesystem confinement with restrictions on permitted build and sync steps.

Changes:

  • Introduces a cap-std-backed filesystem and project vetting.
  • Wires confinement into project loading, local recipes, stores, and network-directory checks.
  • Adds unit and CLI tests, flag documentation, and dependency updates.
File Description
docs/​reference/​cli.md Documents the three new command flags.
crates/​icp-project/​src/​untrusted.rs Adds step validation and a vetted loader.
crates/​icp-project/​src/​store_id.rs Checks ID-store paths against confinement.
crates/​icp-project/​src/​store_artifact.rs Checks artifact and lock paths.
crates/​icp-project/​src/​project.rs Tests confined project loading.
crates/​icp-project/​src/​lib.rs Exposes vetting and its load error.
crates/​icp-project/​src/​files.rs Implements and tests the confined filesystem.
crates/​icp-project/​Cargo.toml Adds the host-only cap-std dependency.
crates/​icp-cli/​tests/​untrusted_tests.rs Tests flag behavior and path restrictions.
crates/​icp-cli/​src/​options.rs Defines the shared untrusted option.
crates/​icp-cli/​src/​main.rs Passes command trust mode into initialization.
crates/​icp-cli/​src/​complete.rs Keeps completion in trusted mode.
crates/​icp-cli/​src/​commands/​sync.rs Adds sync --untrusted.
crates/​icp-cli/​src/​commands/​deploy.rs Adds deploy --untrusted.
crates/​icp-cli/​src/​commands/​build.rs Adds build --untrusted.
crates/​icp-app/​src/​recipe.rs Reads local templates through the filesystem abstraction.
crates/​icp-app/​src/​network/​directory.rs Checks network lock and descriptor paths.
crates/​icp-app/​src/​network/​accessor.rs Applies network-directory confinement checks.
crates/​icp-app/​src/​context/​init.rs Composes confined services and normalizes relative roots.
CHANGELOG.md Announces the untrusted-project feature.
Cargo.toml Declares the workspace cap-std dependency.
Cargo.lock Records dependency resolution changes.

Review effort: Balanced

Comment thread crates/icp-app/src/network/accessor.rs Resolved
Comment thread crates/icp-project/src/files.rs Resolved
Comment thread crates/icp-project/src/untrusted.rs Resolved
Comment thread crates/icp-project/src/untrusted.rs Resolved
Comment thread crates/icp-app/src/recipe.rs Resolved
Copilot AI balanced review requested due to automatic review settings November 10, 2026 17:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Untrusted initialization reads dependency manifests before confinement, while unchecked recipe cache paths can redirect reads and file truncation outside the cache.

2 open issues
2 previously missed issues

In code that hasn't changed since last review

Medium severity Reject nested symlinks in untrusted asset directories

crates/​icp-project/​src/​untrusted.rs:159

A directory passes this check without its contents being vetted, so files: {assets: assets} accepts a nested symlink such as assets/link pointing to another file inside assets. The WASI preopen prevents escapes but permits that internal link to be followed, violating the advertised no-symlinks rule even for a static tree. Validate directory contents or enforce no-follow behavior in the untrusted plugin runtime, and add a nested-symlink regression test.

Low severity Warn callers to discard supplied project state

crates/​icp-cli/​src/​options.rs:51

The help omits the safety prerequisite in untrusted.rs:18–22: callers must discard any .icp supplied with the project, and the CLI does not do this automatically. A regular network descriptor passes the new path checks, but its status-dir can still direct publish_friendly_domains to write outside the root. State the cleanup requirement in the flag help, generated CLI reference and changelog, or enforce rejection or isolation of supplied state.

4 resolved since last review

Review effort: Balanced

Comment thread crates/icp-app/src/context/init.rs Resolved
Copilot AI balanced review requested due to automatic review settings November 10, 2026 17:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

An unchecked HTTP recipe checksum enables a cache-path escape that can truncate a writable host file before checksum verification.

1 open issue
1 previously missed issue

In code that hasn't changed since last review

High severity Unchecked cache checksum enables writes outside the project

crates/​icp-app/​src/​recipe.rs:142

Confining local templates leaves a cache-path escape in the HTTP branch: for an already-cached URL, read_cached_uri_recipe treats the manifest's unchecked sha256 as a directory path. An absolute value naming a supplied project directory with recipe.hbs and an outward .atime symlink makes read_cached_recipe truncate a writable host file before checksum verification, without a race or planted .icp. Validate checksum syntax before cache access and reject path-like cache keys in package.rs.

1 resolved since last review

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings November 10, 2026 17:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Untrusted root discovery reads dependency manifests before confinement, allowing static FIFOs to hang loading and symlinked or out-of-root manifests to bypass intended checks, including during subsequent locator calls.

1 open issue

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings November 10, 2026 21:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human assessment is needed because shell completion bypasses --untrusted safeguards, permitting project-controlled reads and recipe resolution, including FIFO reads that can hang despite the completion timeout.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings November 10, 2026 21:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human assessment is needed because shell completion can ignore --untrusted, allowing file access and recipe processing that the command itself would reject.

No open issues 🎉

Review effort: Balanced

raymondk and others added 6 commits November 10, 2026 22:57
…irectory handle

`--untrusted` on build, deploy and sync limits the steps a project may run
to a pre-built module and a plugin, and reads everything through
`files::Confined` at the project root.

`Confined` opens the root once as a `cap_std::fs::Dir` and makes every read
and write beneath that handle, so the operating system refuses a path that
leaves the root in the same step that opens it (`openat2` on Linux). On top
of that it refuses any path through a symlink, wherever the link leads.

This is #866 and #871 on main, without #868, and with `Confined` built on
cap-std in place of a path walk followed by a plain open.

Co-Authored-By: Claude <noreply@anthropic.com>
… handed, take a recipe path from the working directory

- Confined refuses to read what is not a regular file, before the open: a
  pipe nobody writes to would otherwise hold an args, env or recipe read
  forever.
- vet holds each dirs: and files: entry of a plugin step to a directory, or
  a regular file or directory, in the project. The plugin runtime opens
  those itself.
- A relative file:// recipe path is joined to the working directory before
  it is read, as it resolves for a trusted project, rather than to the
  confined root.
- Say that .icp is the tool's own state and is not vetted, and that the
  plugin runtime opens by path after the look.
- Wrap the host feature list, which had passed taplo's column width.

Co-Authored-By: Claude <noreply@anthropic.com>
What is under .icp is the tool's own state, and whoever takes a project
from elsewhere discards any that came with it. So the id store, the
artifact store and a network's directory go back to what they are on main,
with no confined filesystem handed to them.

Co-Authored-By: Claude <noreply@anthropic.com>
… renders to

A recipe is build instructions read from a file or fetched from a URL
while the project loads. Under --untrusted the loader is now given
untrusted::NoRecipes, a Resolve that fetches nothing, so a project naming
a recipe fails to load, wherever the recipe is from.

RecipeFetcher goes back to what it is on main: it no longer reads a local
template through the project's filesystem, since it is never asked for one
for an untrusted project.

Co-Authored-By: Claude <noreply@anthropic.com>
…is searched for

Finding a workspace root means reading the manifests above the working
directory and following the dependencies they name, with the host's own
filesystem, before there is a root to confine those reads to. It also let
a project above that declares this one be adopted as the root, which
widened what the untrusted project could read to the whole of that
project.

Under --untrusted the root is now the working directory, or the one
--project-root-override names, and the locator is pinned to it. A
directory with no icp.yaml is refused, saying so.

Co-Authored-By: Claude <noreply@anthropic.com>
…holds icp.yaml

It takes that directory as the project root and does not travel up to a
project above. Said in the flag's help, the CLI reference, the project
model and project dependencies pages, the changelog and the module doc.

Co-Authored-By: Claude <noreply@anthropic.com>
@raymondk
raymondk changed the base branch from main to rk/bundle-symlinks November 10, 2026 22:59
@raymondk
raymondk force-pushed the rk/untrusted-cap-std branch from 2f67af7 to 0556a71 Compare November 10, 2026 22:59
@raymondk
raymondk added this pull request to stack #874 November 10, 2026 22:59
Copilot AI balanced review requested due to automatic review settings November 10, 2026 22:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Dynamic shell completion bypasses --untrusted protections, and the CLI help omits the required warning to discard inherited .icp state.

2 open issues

Review effort: Balanced

Comment thread crates/icp-cli/src/complete.rs Resolved
Comment thread crates/icp-cli/src/options.rs Resolved
…is to be deleted

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings November 10, 2026 23:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human review is needed to confirm cap-std containment assumptions and platform CI results for the cross-platform untrusted-project security boundary, accounting for documented exclusions for tool state and ambient plugin opens.

No open issues 🎉

2 resolved since last review

Review effort: Balanced

@raymondk
raymondk marked this pull request as ready for review November 11, 2026 00:35
@raymondk
raymondk requested a review from a team as a code owner November 11, 2026 00:35
//! step is a pre-built module, a sync step is a plugin, and the file either
//! names is a regular file that filesystem will give.
//!
//! What the tool keeps under `.icp` is not part of the project: it is state

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving .icp unguarded lets a hostile checkout overwrite the user's files. The artifact store writes build output straight to the host path .icp/cache/artifacts/<canister> (store_artifact.rs, save), following symlinks. A git checkout can carry .icp/cache/artifacts/backend -> ~/.bashrc, and icp build --untrusted then overwrites ~/.bashrc with wasm bytes. The same directory also holds the canister-id store, which decides which canisters deploy installs to, and the network descriptor, which decides where the CLI connects.

Right now the only protection is the flag help saying to delete .icp first. Deleting it outright would break a service that re-deploys the same tree, but cheap enforcement is possible: under --untrusted, refuse if .icp is a symlink or contains any. A fuller fix would route the stores through the confined handle.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is, by design. A bundle is expected to carry no deployment-specific information: no canister ids, no network descriptor, no .icp at all. Whoever deploys one adds their own settings before deploying, so .icp under --untrusted is state the deployer's own runs wrote, not something that came with the project. The flag help, the CLI reference and the module doc say to delete one that did come with it.

impl<L> Vetted<L> {
/// `files` is what the paths are resolved through: the filesystem the
/// project is on.
pub fn new(loader: L, files: Arc<dyn FileSystem>) -> Self {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vet no longer checks containment itself; it relies on files being Confined. So a library user who writes Vetted::new(loader, Arc::new(HostFileSystem)) gets only a regular-file check: a module at ../../elsewhere.wasm or through a symlink passes, with no error to say anything is missing. Could this take Confined concretely, or at least carry a warning in the doc here that files must be the confined filesystem?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the warning in 9a929ea, on both vet and Vetted::new: vet checks no containment itself, so files must be a filesystem that refuses a path outside the root or through a symlink, and HostFileSystem is not one. It cannot take Confined concretely because Confined is behind the host feature and vet is in the core that builds without it.

… the filesystem it is given

vet asks the filesystem whether a path is a file or a directory and takes
its word, so Vetted::new with HostFileSystem accepts a path outside the
root or behind a symlink without an error. Say so on both.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings November 11, 2026 19:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human security review is needed to validate the cross-platform filesystem boundary for deploy-service inputs, particularly trust in existing .icp state, stable-tree assumptions, and exclusions for ambient plugin opens and unconfined tool state.

No open issues 🎉

Review effort: Balanced

@raymondk
raymondk merged commit 8f98455 into main Nov 11, 2026
111 checks passed
@raymondk
raymondk deleted the rk/untrusted-cap-std branch November 11, 2026 20:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants