Skip to content

feat(project): a filesystem confined to the project root, for a project from elsewhere - #866

Closed
raymondk wants to merge 6 commits into
mainfrom
rk/confined-project-load
Closed

raymondk wants to merge 6 commits into
mainfrom
rk/confined-project-load

Conversation

@raymondk

@raymondk raymondk commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

This is the library that a user (like the mcp server) would use to load an untrusted project.


Loading a project holds nothing: the loader joins each path a manifest names to its base and reads it through the host filesystem. Right for the user's own tree, wrong for one someone else handed over, where a manifest can name ../../etc/hostname as an argument file and have it read into the canister's arguments at load, before any caller could check.

files::Confined is the host filesystem held to one directory. Every path must name something under the root, reached through no symlink:

  • The root is absolute. Confined::new returns RootNotAbsolute otherwise, since every path is matched against the root's spelling. It is matched as the caller spells it and never canonicalized, which is what keeps it working on macOS (/var is /private/var) and Windows. The root and everything above it are the caller's and may be reached through a link.
  • Everything is under the root. A relative path is joined to the root, . dropped, .. applied, and a path that comes to rest outside — by climbing, or by being absolute elsewhere — is refused with OutsideRoot.
  • No symlinks below the root. Each component is looked at on disk as it is named, and a path that passes through a symlink is refused with ThroughLink, wherever the link leads and even where a later .. steps back out of it (link/../file).

The predicates answer false for a refused path, scratch directories are made under the root, and resolve hands out the checked path to a caller that needs one.

The loader resolves every path through the filesystem it is given, so confining that one seam covers all of them, canister directories and dependency projects included. A local recipe template is the one file the loader does not read through this seam: the Resolve implementation reads it, and must read it through this filesystem or refuse it (#871 does the former). Confined holds reads and writes and nothing else: a script build step still runs any command.

Known issue, recorded in the doc: the loader asks is_file before it reads, and a predicate cannot say why, so canisters: [../elsewhere] fails as "not found" rather than as outside the root.

The tests in project.rs load one manifest through both filesystems to show the difference. The CLI's default loading is unchanged, so there is no changelog entry here; #871 uses Confined for --untrusted.

Consumer: dfinity/imcp2#230 pins this branch and replaces its own index-backed filesystem with Confined. Note for it: Confined::new now returns a Result.

Ref: #865

🤖 Generated with Claude Code

Copilot AI balanced review requested due to automatic review settings November 4, 2026 00:32

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 confinement gaps allow public resolution, dangling symlinks, and concurrent symlink replacement to bypass the project root and access or create files outside it.

4 open issues
What changed in this PR

Adds an opt-in Confined filesystem to icp-project for loading externally supplied projects within a designated root, while leaving the CLI’s HostFileSystem behavior unchanged.

Changes:

  • Adds path containment checks, confinement errors, and root-local scratch directories.
  • Adds filesystem tests for traversal, symlinks, and scratch-file operations.
  • Adds loader tests comparing host and confined access to outside argument files and canister directories.
File Description
crates/​icp-project/​src/​project.rs Adds integration tests comparing host and confined project loading.
crates/​icp-project/​src/​files.rs Implements Confined, its public API and errors, and filesystem tests.

Review effort: Balanced

Comment thread crates/icp-project/src/files.rs Resolved
Comment thread crates/icp-project/src/files.rs Resolved
Comment thread crates/icp-project/src/files.rs Resolved
Comment thread crates/icp-project/src/files.rs Resolved
raymondk added a commit to dfinity/imcp2 that referenced this pull request Nov 4, 2026
The index-backed filesystem went: the confinement it provided is now the
library's own. dfinity/icp-cli#866 (issue #865) adds `files::Confined`,
the host filesystem held to one directory: every path is resolved
against the root before the disk sees it, and one that comes to rest
outside — by climbing, by being absolute elsewhere, or through a symlink
inside pointing out — is refused. The loader resolves every path a
manifest names through the filesystem it is given, so that one seam
holds all of them, canister directories included, which is also why the
spelling check on canister directories went: a directory outside the
root is not found, and the project does not load.

What stays here is what the library cannot know about an archive: the
bounds on its size, its decompressed size, its files and directories
and their paths, the refusal of symlinks, device nodes and `.icp`, the
metered view the loader reads through, and the checks on the bundle's
shape before any phase runs.

The four icp-cli crates are pinned to that branch's head until it is on
main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@raymondk
raymondk added this pull request to stack #869 November 4, 2026 19:54
raymondk added a commit that referenced this pull request Nov 4, 2026
A symlink inside a plugin's `files:` or `dirs:` directory was recorded
as a link entry with its target unchecked, so a link to a file outside
the project reached that file on whatever machine unpacked the bundle.
A `files:` entry that was itself a symlink to a file outside the
project was read straight into the bundle: it was validated lexically
before the build, when it need not have existed, and the read followed
the link.

The archive walk is now the bundler's own rather than tar's. Every
entry met is canonicalized and must land under the canonical project
directory: one that does is appended as what it points at, so a project
may link to its own files freely, and one that does not fails the
bundle, naming the link and where it leads. A dangling link, a link
back to a directory being copied, and anything that is neither a file
nor a directory fail the same way. A `files:` entry is held to the
project again once the build has run and it exists.

The walk appends entries in sorted order, which makes the archive
reproducible for a given tree where tar's own walk left the order to
the filesystem.

This is the bundling side of the rule that `files::Confined` (#866)
relies on: a bundle holds no symlinks, a consumer refuses any archive
entry that is not a regular file or directory (#863), and so the tree
a confined loader reads has nothing in it that leads elsewhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@raymondk
raymondk marked this pull request as ready for review November 5, 2026 01:09
@raymondk
raymondk requested a review from a team as a code owner November 5, 2026 01:09
raymondk and others added 2 commits November 5, 2026 07:46
…ct from elsewhere

`icp project bundle` holds every path a manifest names to the project it
is bundling, and refuses one that resolves outside. Loading a project
holds nothing: the loader joins each path to its base and reads it
through the host filesystem, which is right for the user's own tree and
wrong for a project someone else handed over, a bundle an agent passes
to a deploy service, say. A manifest in such a bundle could name
`../../etc/hostname` as an argument file and have it read into the
canister's arguments at load, before any caller could check.

`files::Confined` is the host filesystem held to one directory. Every
path is resolved against the root before the disk sees it: a relative
path is joined, `.` dropped, `..` applied, and a path that comes to rest
outside the root, by climbing, by being absolute elsewhere, or through a
symlink inside the root that points out, is refused with `OutsideRoot`.
The predicates answer false for such a path, scratch directories are made
under the root, and `resolve` hands out the real path for a caller that
needs one for something that does not read through the trait.

The loader resolves every path a manifest names through the filesystem
it is given, so confining that one seam holds all of them, canister
directories and dependency projects included, without the loader
enumerating its fields; the tests load one manifest through both
filesystems to show the difference. Nothing changes for the CLI, which
keeps loading through `HostFileSystem`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The symlink walk was the wrong layer. A symlink is the business of
whoever puts a tree on disk: bundling writes only regular files and
directories, and a consumer unpacking an archive it does not trust
refuses any entry that is not, so the tree Confined reads has no links
to follow. The user's own tree may link wherever it likes, and the host
follows it. Confined now resolves every path lexically against the root
and does nothing else.

Canonicalizing the root in `new` is also what failed CI on macOS and
Windows: the root was stored as `/private/var/...` or `runneradmin`,
while every absolute path the loader passed was spelled as the caller
got it (`/var/...`, `RUNNER~1`), and so refused. The root is now kept
as spelled, and a unix test reproduces the failure through a symlinked
alias of the project directory.

The doc claim that one seam holds every loader read is narrowed: a
local recipe template is read by the `Resolve` implementation, which a
consumer holding an untrusted tree must confine or refuse itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
raymondk added a commit that referenced this pull request Nov 5, 2026
A symlink inside a plugin's `files:` or `dirs:` directory was recorded
as a link entry with its target unchecked, so a link to a file outside
the project reached that file on whatever machine unpacked the bundle.
A `files:` entry that was itself a symlink to a file outside the
project was read straight into the bundle: it was validated lexically
before the build, when it need not have existed, and the read followed
the link.

The archive walk is now the bundler's own rather than tar's. Every
entry met is canonicalized and must land under the canonical project
directory: one that does is appended as what it points at, so a project
may link to its own files freely, and one that does not fails the
bundle, naming the link and where it leads. A dangling link, a link
back to a directory being copied, and anything that is neither a file
nor a directory fail the same way. A `files:` entry is held to the
project again once the build has run and it exists.

The walk appends entries in sorted order, which makes the archive
reproducible for a given tree where tar's own walk left the order to
the filesystem.

This is the bundling side of the rule that `files::Confined` (#866)
relies on: a bundle holds no symlinks, a consumer refuses any archive
entry that is not a regular file or directory (#863), and so the tree
a confined loader reads has nothing in it that leads elsewhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@raymondk
raymondk force-pushed the rk/confined-project-load branch from c2b8b36 to 6f5b535 Compare November 5, 2026 12:46
Comment thread crates/icp-project/src/files.rs Resolved
Comment thread crates/icp-project/src/files.rs Resolved
Co-Authored-By: Claude <noreply@anthropic.com>
raymondk added a commit that referenced this pull request Nov 10, 2026
A symlink inside a plugin's `files:` or `dirs:` directory was recorded
as a link entry with its target unchecked, so a link to a file outside
the project reached that file on whatever machine unpacked the bundle.
A `files:` entry that was itself a symlink to a file outside the
project was read straight into the bundle: it was validated lexically
before the build, when it need not have existed, and the read followed
the link.

The archive walk is now the bundler's own rather than tar's. Every
entry met is canonicalized and must land under the canonical project
directory: one that does is appended as what it points at, so a project
may link to its own files freely, and one that does not fails the
bundle, naming the link and where it leads. A dangling link, a link
back to a directory being copied, and anything that is neither a file
nor a directory fail the same way. A `files:` entry is held to the
project again once the build has run and it exists.

The walk appends entries in sorted order, which makes the archive
reproducible for a given tree where tar's own walk left the order to
the filesystem.

This is the bundling side of the rule that `files::Confined` (#866)
relies on: a bundle holds no symlinks, a consumer refuses any archive
entry that is not a regular file or directory (#863), and so the tree
a confined loader reads has nothing in it that leads elsewhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
raymondk added a commit that referenced this pull request Nov 10, 2026
A project the CLI loads is trusted the way the user's own checkout is:
a build step may run any command, a recipe may come from any file or
URL, a module may be downloaded from anywhere, and a manifest may name
any path on the machine, through any symlink. A project from elsewhere
(a bundle an agent produced, a checkout nobody has read) should get none
of that, and until now nothing in the crate said what it should get
instead; a consumer such as the imcp2 deploy server had to assemble the
rule itself from the seams.

`icp_project::untrusted` now states the rule, which is the shape
`icp project bundle` writes, and supplies the pieces that hold a project
to it:

- `files::Confined::refusing_links` is the confined filesystem of #866
  with one more refusal: a path that passes through a symlink anywhere
  under the root, wherever the link leads. The lexical `Confined::new`
  stays as it is, for a tree whose links were refused on the way in.
  Each component below the root is looked at as it is on disk, so no
  canonicalizing, and the root is still matched as the caller spells it.
- `untrusted::NoRecipes` is the `Resolve` that fetches nothing, so a
  project naming a recipe fails to load rather than loading and then
  being refused: the recipe is build instructions read from wherever the
  manifest points, and the loader reads it before anyone can look.
- `untrusted::vet` refuses a loaded project with a script build or sync
  step, or a module or sync plugin fetched from a URL, and
  `untrusted::Vetted` wraps a `ProjectLoad` so every load is vetted.

The CLI gets a global `--untrusted` flag that composes the three: the
project root is located up front, the filesystem is confined to it and
refuses links, no recipe is fetched, and the loader is vetted. Nothing
changes without the flag. A checksum on a module is not asked for: the
same untrusted hand wrote the module and the checksum, so it says
nothing about what the project can reach.

Ref: #863, #865

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

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

A relative filesystem root causes returned directory entries to be resolved with the root duplicated, breaking reads and glob discovery.

1 open issue
4 resolved since last review

Review effort: Balanced

Comment thread crates/icp-project/src/files.rs Resolved
raymondk added a commit that referenced this pull request Nov 10, 2026
A project the CLI loads is trusted the way the user's own checkout is:
a build step may run any command, a recipe may come from any file or
URL, a module may be downloaded from anywhere, and a manifest may name
any path on the machine, through any symlink. A project from elsewhere
(a bundle an agent produced, a checkout nobody has read) should get none
of that, and until now nothing in the crate said what it should get
instead; a consumer such as the imcp2 deploy server had to assemble the
rule itself from the seams.

`icp_project::untrusted` now states the rule, which is the shape
`icp project bundle` writes, and supplies the pieces that hold a project
to it:

- `files::Confined::refusing_links` is the confined filesystem of #866
  with one more refusal: a path that passes through a symlink anywhere
  under the root, wherever the link leads. The lexical `Confined::new`
  stays as it is, for a tree whose links were refused on the way in.
  Each component below the root is looked at as it is on disk, so no
  canonicalizing, and the root is still matched as the caller spells it.
- `untrusted::NoRecipes` is the `Resolve` that fetches nothing, so a
  project naming a recipe fails to load rather than loading and then
  being refused: the recipe is build instructions read from wherever the
  manifest points, and the loader reads it before anyone can look.
- `untrusted::vet` refuses a loaded project with a script build or sync
  step, or a module or sync plugin fetched from a URL, and
  `untrusted::Vetted` wraps a `ProjectLoad` so every load is vetted.

The CLI gets a global `--untrusted` flag that composes the three: the
project root is located up front, the filesystem is confined to it and
refuses links, no recipe is fetched, and the loader is vetted. Nothing
changes without the flag. A checksum on a module is not asked for: the
same untrusted hand wrote the module and the checksum, so it says
nothing about what the project can reach.

Ref: #863, #865

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A path through a symlink below the root is refused wherever the link
leads, so the type is a boundary on its own rather than only for a tree
someone else has already held to shape. The root must be absolute, since
every path is matched against its spelling.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings November 10, 2026 13:54
raymondk added a commit that referenced this pull request Nov 10, 2026
A symlink inside a plugin's `files:` or `dirs:` directory was recorded
as a link entry with its target unchecked, so a link to a file outside
the project reached that file on whatever machine unpacked the bundle.
A `files:` entry that was itself a symlink to a file outside the
project was read straight into the bundle: it was validated lexically
before the build, when it need not have existed, and the read followed
the link.

The archive walk is now the bundler's own rather than tar's. Every
entry met is canonicalized and must land under the canonical project
directory: one that does is appended as what it points at, so a project
may link to its own files freely, and one that does not fails the
bundle, naming the link and where it leads. A dangling link, a link
back to a directory being copied, and anything that is neither a file
nor a directory fail the same way. A `files:` entry is held to the
project again once the build has run and it exists.

The walk appends entries in sorted order, which makes the archive
reproducible for a given tree where tar's own walk left the order to
the filesystem.

This is the bundling side of the rule that `files::Confined` (#866)
relies on: a bundle holds no symlinks, a consumer refuses any archive
entry that is not a regular file or directory (#863), and so the tree
a confined loader reads has nothing in it that leads elsewhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
raymondk added a commit that referenced this pull request Nov 10, 2026
A project the CLI loads is trusted the way the user's own checkout is:
a build step may run any command, a recipe may come from any file or
URL, a module may be downloaded from anywhere, and a manifest may name
any path on the machine, through any symlink. A project from elsewhere
(a bundle an agent produced, a checkout nobody has read) should get none
of that, and until now nothing in the crate said what it should get
instead; a consumer such as the imcp2 deploy server had to assemble the
rule itself from the seams.

`icp_project::untrusted` now states the rule, which is the shape
`icp project bundle` writes, and supplies the pieces that hold a project
to it:

- `files::Confined::refusing_links` is the confined filesystem of #866
  with one more refusal: a path that passes through a symlink anywhere
  under the root, wherever the link leads. The lexical `Confined::new`
  stays as it is, for a tree whose links were refused on the way in.
  Each component below the root is looked at as it is on disk, so no
  canonicalizing, and the root is still matched as the caller spells it.
- `untrusted::NoRecipes` is the `Resolve` that fetches nothing, so a
  project naming a recipe fails to load rather than loading and then
  being refused: the recipe is build instructions read from wherever the
  manifest points, and the loader reads it before anyone can look.
- `untrusted::vet` refuses a loaded project with a script build or sync
  step, or a module or sync plugin fetched from a URL, and
  `untrusted::Vetted` wraps a `ProjectLoad` so every load is vetted.

The CLI gets a global `--untrusted` flag that composes the three: the
project root is located up front, the filesystem is confined to it and
refuses links, no recipe is fetched, and the loader is vetted. Nothing
changes without the flag. A checksum on a module is not asked for: the
same untrusted hand wrote the module and the checksum, so it says
nothing about what the project can reach.

Ref: #863, #865

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

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

Filesystem confinement rejects canonical paths under symlinked roots, breaking app-image bundling, while absolute in-root canister globs silently lose all matches.

No open issues 🎉

2 previously missed issues

In code that hasn't changed since last review

High severity Canonical paths fail under symlinked roots

crates/​icp-project/​src/​files.rs:272

When the root is reached through a symlink, including /var/... on macOS, canonicalize returns an in-root path that resolve rejects because it lacks the caller-spelled root prefix. This breaks app-image bundling through Confined: operations/bundle.rs:1380 stores the canonical source and line 1541 reads it through the same filesystem. Make canonical results reusable without weakening containment, for example by accepting a validated canonical-root alias alongside the original spelling, and add an alias-root round-trip test.

Medium severity Absolute in-root glob patterns fail to match

crates/​icp-project/​src/​files.rs:249

Absolute in-root canister globs silently lose all matches: with root /tmp/project, canisters: ["/tmp/project/canisters/*"] makes expand_glob probe exists("/tmp"), which returns false and clears the frontier before it reaches the root. Consolidation then loads no canisters from that pattern. Assemble the literal absolute prefix in expand_glob before probing it, keep outside-root predicates false, and add a confined absolute-glob regression test.

1 resolved since last review

Review effort: Balanced

raymondk and others added 2 commits November 10, 2026 14:36
…issing

Co-Authored-By: Claude <noreply@anthropic.com>
… any '..' after it

The link check ran on the path after '..' was applied, so 'link/../file'
cancelled the link out by its spelling while the host, handed the path as
written, would follow the link and take '..' from where it leads. Each
component is now looked at as it is named. The doc says that Confined
holds reads and writes and nothing else.

Co-Authored-By: Claude <noreply@anthropic.com>
raymondk added a commit that referenced this pull request Nov 10, 2026
A symlink inside a plugin's `files:` or `dirs:` directory was recorded
as a link entry with its target unchecked, so a link to a file outside
the project reached that file on whatever machine unpacked the bundle.
A `files:` entry that was itself a symlink to a file outside the
project was read straight into the bundle: it was validated lexically
before the build, when it need not have existed, and the read followed
the link.

The archive walk is now the bundler's own rather than tar's. Every
entry met is canonicalized and must land under the canonical project
directory: one that does is appended as what it points at, so a project
may link to its own files freely, and one that does not fails the
bundle, naming the link and where it leads. A dangling link, a link
back to a directory being copied, and anything that is neither a file
nor a directory fail the same way. A `files:` entry is held to the
project again once the build has run and it exists.

The walk appends entries in sorted order, which makes the archive
reproducible for a given tree where tar's own walk left the order to
the filesystem.

This is the bundling side of the rule that `files::Confined` (#866)
relies on: a bundle holds no symlinks, a consumer refuses any archive
entry that is not a regular file or directory (#863), and so the tree
a confined loader reads has nothing in it that leads elsewhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings November 10, 2026 14:48
raymondk added a commit that referenced this pull request Nov 10, 2026
A project the CLI loads is trusted the way the user's own checkout is:
a build step may run any command, a recipe may come from any file or
URL, a module may be downloaded from anywhere, and a manifest may name
any path on the machine, through any symlink. A project from elsewhere
(a bundle an agent produced, a checkout nobody has read) should get none
of that, and until now nothing in the crate said what it should get
instead; a consumer such as the imcp2 deploy server had to assemble the
rule itself from the seams.

`icp_project::untrusted` now states the rule, which is the shape
`icp project bundle` writes, and supplies the pieces that hold a project
to it:

- `files::Confined::refusing_links` is the confined filesystem of #866
  with one more refusal: a path that passes through a symlink anywhere
  under the root, wherever the link leads. The lexical `Confined::new`
  stays as it is, for a tree whose links were refused on the way in.
  Each component below the root is looked at as it is on disk, so no
  canonicalizing, and the root is still matched as the caller spells it.
- `untrusted::NoRecipes` is the `Resolve` that fetches nothing, so a
  project naming a recipe fails to load rather than loading and then
  being refused: the recipe is build instructions read from wherever the
  manifest points, and the loader reads it before anyone can look.
- `untrusted::vet` refuses a loaded project with a script build or sync
  step, or a module or sync plugin fetched from a URL, and
  `untrusted::Vetted` wraps a `ProjectLoad` so every load is vetted.

The CLI gets a global `--untrusted` flag that composes the three: the
project root is located up front, the filesystem is confined to it and
refuses links, no recipe is fetched, and the loader is vetted. Nothing
changes without the flag. A checksum on a module is not asked for: the
same untrusted hand wrote the module and the checksum, so it says
nothing about what the project can reach.

Ref: #863, #865

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

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 path-containment checks can reject initialization argument paths that normalize to valid in-root files.

No open issues 🎉

Review effort: Balanced

@raymondk
raymondk removed this pull request from stack #869 November 10, 2026 22:57
raymondk added a commit that referenced this pull request Nov 10, 2026
A symlink inside a plugin's `files:` or `dirs:` directory was recorded
as a link entry with its target unchecked, so a link to a file outside
the project reached that file on whatever machine unpacked the bundle.
A `files:` entry that was itself a symlink to a file outside the
project was read straight into the bundle: it was validated lexically
before the build, when it need not have existed, and the read followed
the link.

The archive walk is now the bundler's own rather than tar's. Every
entry met is canonicalized and must land under the canonical project
directory: one that does is appended as what it points at, so a project
may link to its own files freely, and one that does not fails the
bundle, naming the link and where it leads. A dangling link, a link
back to a directory being copied, and anything that is neither a file
nor a directory fail the same way. A `files:` entry is held to the
project again once the build has run and it exists.

The walk appends entries in sorted order, which makes the archive
reproducible for a given tree where tar's own walk left the order to
the filesystem.

This is the bundling side of the rule that `files::Confined` (#866)
relies on: a bundle holds no symlinks, a consumer refuses any archive
entry that is not a regular file or directory (#863), and so the tree
a confined loader reads has nothing in it that leads elsewhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
raymondk added a commit that referenced this pull request Nov 10, 2026
…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>
@raymondk raymondk closed this Nov 11, 2026
raymondk added a commit that referenced this pull request Nov 11, 2026
* fix(bundle): hold a bundle to regular files and directories

A symlink inside a plugin's `files:` or `dirs:` directory was recorded
as a link entry with its target unchecked, so a link to a file outside
the project reached that file on whatever machine unpacked the bundle.
A `files:` entry that was itself a symlink to a file outside the
project was read straight into the bundle: it was validated lexically
before the build, when it need not have existed, and the read followed
the link.

The archive walk is now the bundler's own rather than tar's. Every
entry met is canonicalized and must land under the canonical project
directory: one that does is appended as what it points at, so a project
may link to its own files freely, and one that does not fails the
bundle, naming the link and where it leads. A dangling link, a link
back to a directory being copied, and anything that is neither a file
nor a directory fail the same way. A `files:` entry is held to the
project again once the build has run and it exists.

The walk appends entries in sorted order, which makes the archive
reproducible for a given tree where tar's own walk left the order to
the filesystem.

This is the bundling side of the rule that `files::Confined` (#866)
relies on: a bundle holds no symlinks, a consumer refuses any archive
entry that is not a regular file or directory (#863), and so the tree
a confined loader reads has nothing in it that leads elsewhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(bundle): refuse the output, a non-UTF-8 name and a looping link by name

Three holes in the archive walk, from review:

- The output path is checked against the copied directories by its
  spelling, which a symlink inside one of them gets around: with
  `assets/shared -> ../shared` and `--output shared/bundle.tar.gz`, the
  walk met the archive it was writing and copied a partial copy of it
  into itself. The canonical output now travels with the walk, and an
  entry that resolves to it is refused, named by the path it was
  reached by.
- `crate::fs::read_dir` skips a name that is not UTF-8, which for a
  bundle meant a file silently left out where tar's walk had carried
  it. The walk lists directories itself and refuses such a name, since
  the archive cannot carry it either way.
- A link was called dangling whenever canonicalizing it failed and
  `read_link` succeeded, which also covers a link that loops or leads
  somewhere unreadable. Only a `NotFound` is dangling now; the rest
  keep their own cause.

Entries met under a link are listed where the directory really is but
named by the path they were reached by, so every message says what the
user wrote rather than where it led.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(bundle): keep the Intel macOS chore out of Experimental, drop a stale doc comment

The new `## Experimental` heading sat above the existing Intel macOS
chore entry, which made that general change read as experimental. The
heading and the bundle entry now follow it, as in v1.8.0.

`validate_output_path` kept its old doc comment above the new one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(bundle): resolve each directory once on the walk, and list names

The walk resolved a directory as an entry and again on entering it; the
resolved path is now handed down. list_dir returns names rather than paths
the caller took the names back out of. Doc comments and the changelog entry
say the same in fewer words.

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

* fix(bundle): resolve the output once it exists, for the walk to refuse

The canonical output was worked out before the build, when an output path
that is a dangling symlink resolves to its own spelling. Creating the file
follows the link, so the walk was told to refuse a path the bundle was not
written to, and one linked into a copied directory was read into itself
without end. The output is now resolved after it is created.

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

* fix(bundle): hold a files: entry to what the walk holds, and resolve from where a link led

A files: entry that is itself a link to the output was read into the
bundle half-written, and one that is a named pipe was read forever; both
are now refused as they are on the walk. The walk resolved each entry by
the path it was reached by, following every link crossed so far again, so
a chain of directory links longer than the host follows in one path
failed; each entry is now resolved from the directory's real path.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
raymondk added a commit that referenced this pull request Nov 11, 2026
…irectory handle (#873)

* feat(project): --untrusted, with the project read beneath a cap-std directory 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>

* fix(untrusted): refuse a pipe before it is read, vet what a plugin is 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>

* refactor(untrusted): leave .icp to the tool, unguarded

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>

* feat(untrusted): refuse a recipe, rather than read it and vet what it 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>

* fix(untrusted): the project is where the command stands, and no root 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>

* docs(untrusted): say that --untrusted is run from the directory that 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>

* docs(untrusted): say in the flag's help that .icp is not checked and is to be deleted

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

* docs(untrusted): say that vet checks no containment and takes it from 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>

---------

Co-authored-by: Claude <noreply@anthropic.com>
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