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
feat(project): --untrusted, with the project read beneath a cap-std directory handle - #873
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.
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.
.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.
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.
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.
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.
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.
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.
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.
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.
…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>
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.
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.
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.
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?
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>
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.
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.
An alternative to the #866 → #871 stack, for comparison: the same
--untrustedfeature, withfiles::Confinedbuilt oncap-stdinstead of a path walk followed by a plain open. It is stacked on #868 (the bundle changes), which now sits onmainby itself.What is different from #866 / #871
Confined::newopens the project root once as acap_std::fs::Dir. EveryFileSystemread, 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 oneopenat2call; elsewherecap-stdwalks the path a handle at a time.is outside,passes through) are unchanged.canonicalizereturns 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/varon macOS).Confined::newalso fails if the root cannot be opened.RootNotAbsolutebecomesRootError { NotAbsolute, Open }.Also different from #871
.icpis not guarded. It is the tool's own state, and whoever takes a project from elsewhere is expected to discard any.icpthat came with it. The id store, artifact store and network directory are as they are onmain.dirs:entry must be a directory, and eachfiles:entry a regular file or directory, in the project.untrusted::NoRecipes, aResolvethat 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.RecipeFetcheris as it is onmain.--project-root-overridenames. 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
vethas looked. Those get the rule, not the handle's guarantee.tempfiledirectly under the root.Dependency
cap-std = "4", behindicp-project'shostfeature.cap-primitives4.0.3 was already in the lockfile;cap-stdis the only new package. Resolving it also moved thewindows-sysversion of several existing packages, and onegetrandom, inCargo.lock.Testing
On Linux: fmt, clippy, the
wasm32-unknown-unknownbuild,check-no-host-reach.sh,icp-projecttests with and withouthost,icp-apptests, 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