Skip to content

feat(project): an untrusted project is held to the shape a bundle has - #871

Draft
raymondk wants to merge 7 commits into
rk/bundle-symlinksfrom
rk/trusted-shape
Draft

raymondk wants to merge 7 commits into
rk/bundle-symlinksfrom
rk/trusted-shape

Conversation

@raymondk

@raymondk raymondk commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #868, which is stacked on #866. The rule for a project someone else handed over, applied to the loaded project, and a flag on the commands a deploy service runs.


A project the CLI loads is trusted the way the user's own checkout is: a build step may run any command, a sync step any script, and either may take its module or plugin from anywhere on the machine. A project from elsewhere — a bundle an agent produced, a checkout nobody has read — may not, and nothing in the crate said what it may do instead (#863).

The line is between reading and running. Reading is not restricted: argument files, manifests and recipe templates are read from wherever the manifest points, through the ordinary filesystem and fetchers. What runs is held to a list, stated as what is allowed so that a kind of step added later is refused until someone decides otherwise:

  • a build step is a pre-built module;
  • a sync step is a plugin;
  • the file each names is within the project, judged by where it really is: root and file both canonicalized, so a link out is refused for where it leads, a link within is let be, and .. counts for what it does.

A recipe is rendered as the project loads, and the steps it produces are held like any other. A module or plugin named by URL is not on the list. untrusted::vet holds a loaded Project to it and names the first thing that is not, with the step on one line; untrusted::Vetted wraps any ProjectLoad so every load is vetted. Nothing else in the crate changes.

icp build, icp deploy and icp sync get --untrusted, which wraps the loader; each refuses before reaching any network. No other command takes the flag, and nothing changes without it.

$ icp build --untrusted
Error: the project asks for more than an untrusted project may

Caused by:
    canister 'backend' names '/srv/bundle/linked.wasm', which resolves to '/home/me/secret.wasm', outside the project at '/srv/bundle': an untrusted project runs only what is within it

A library consumer wraps its loader the same way: Vetted::new(loader, files).

Decisions worth a look:

  • No checksum is required on a module: the same untrusted hand wrote the module and the checksum, so it says nothing about what runs.
  • Recipes are allowed: rendering one is a read, and the steps it renders to are vetted. An earlier revision refused them.
  • No second refusal in the Build and ScriptRunner seams: the vetted loader is the only way a step reaches them.

Tests: the vet on the project model, with each kind of step on both sides of the list, a link out, a link within, a chain of links, a linked directory, a climb out and a missing file; and eight CLI integration tests covering each refusal, reading outside the project, a recipe rendering to each, a relative root, and that deploy and sync refuse as build does while no other command takes the flag.

Ref: #863, #865

🤖 Generated with Claude Code

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>
@raymondk
raymondk added this pull request to stack #869 November 5, 2026 21:54
… not

`untrusted::vet` listed the steps it refused: a script, a module from a
URL, a plugin from a URL. A kind of step added later would have passed
unexamined. It now holds every step to the two an untrusted project may
have, `build_step_allowed` (a pre-built module read from a file in the
project) and `sync_step_allowed` (a plugin read from a file in the
project), and names the first step that is neither, with the step
itself on one line so the reader sees what it was.

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

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 untrusted-mode bypasses in artifact and lock writes, local Wasm loading, managed-image mounts, and shell completion still permit reads or writes outside the project.

5 open issues
What changed in this PR

Adds an opt-in --untrusted mode intended to restrict externally supplied projects to bundle-like behavior, building on the existing filesystem confinement and bundle safeguards.

Changes:

  • Adds reusable step vetting, recipe refusal, and a vetted project loader.
  • Extends confined filesystem access to reject symlinks below the project root.
  • Wires the policy into CLI initialization, with tests and documentation.
File Description
docs/​reference/​cli.md Documents the global --untrusted flag.
crates/​icp-project/​src/​untrusted.rs Defines allowed steps, recipe refusal, and loader vetting.
crates/​icp-project/​src/​lib.rs Exposes the policy module and load error.
crates/​icp-project/​src/​files.rs Adds symlink-refusing confinement and tests.
crates/​icp-cli/​tests/​untrusted_tests.rs Tests accepted projects, refusals, and flag handling.
crates/​icp-cli/​src/​main.rs Parses and forwards the new flag.
crates/​icp-cli/​src/​complete.rs Supplies the trust argument for completion initialization.
crates/​icp-app/​src/​context/​init.rs Composes confinement, recipe refusal, and loader vetting.
CHANGELOG.md Announces untrusted mode and its intended restrictions.

Review effort: Balanced

Comment thread crates/icp-app/src/context/init.rs Resolved
Comment thread crates/icp-app/src/context/init.rs Resolved
Comment thread crates/icp-cli/src/complete.rs Resolved
Comment thread crates/icp-project/src/untrusted.rs Resolved
Comment thread crates/icp-project/src/untrusted.rs Resolved
…d anchor a relative root

Two gaps a review turned up in what `--untrusted` holds a project to.

The module a pre-built step or a plugin step names was fetched by the
CLI's shared fetcher, which reads a local source straight off the host:
its checksum was taken of a file outside the root or through a symlink,
and for a plugin the unchecked path went on to Wasmtime, which opens it
for itself. `untrusted::LocalModules` is the `wasm::Fetch` of an
untrusted project: every module is read through the confined filesystem
first, so the refusal happens before any checksum and before the path
goes anywhere, and its message is the filesystem's own; a module named
by URL is not fetched.

A relative `--project-root-override` or `ICP_PROJECT_ROOT`, such as
`bundle`, came back from the locator as spelled, and the confined
filesystem then held `bundle/icp.yaml` to `bundle/bundle/icp.yaml`, so a
valid project failed to load. The override is now joined to the working
directory before the locator is built, spelled as the user spelled it,
with nothing canonicalized.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread crates/icp-project/src/untrusted.rs Resolved
Comment thread crates/icp-project/src/untrusted.rs Resolved
raymondk and others added 4 commits November 6, 2026 14:38
A deploy service handed a project by an agent is what asks for the flag,
and those are the commands it runs; nothing else acts on what a project
says. The flag is read off the command before the context is composed,
since it is the context that applies it. On every other command it is
now an unexpected argument.

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

The vet has been a list of what is allowed since ae3367e; the flag's
help, the changelog, the context's doc comment and the symlink message
still read as a list of refusals. Each now states the list — a pre-built
module from a file in the project, a plugin from a file in the project,
no recipe, every path a regular file or directory under the root — and
that anything else is refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The line is between reading and running. Reading is not restricted: the
loader reads argument files, manifests and recipe templates from wherever
the manifest points, through the ordinary host filesystem and fetchers,
and a recipe's rendered steps are held like any other. What runs is held
to a list: a build step is a pre-built module, a sync step is a plugin,
and the file each names is within the project — judged by where it
really is, root and file both canonicalized, so a link is seen for where
it leads and a link within the project is let be.

That makes the vet the whole rule, applied to the loaded project, and
retires the link-refusing filesystem, the recipe refusal, the module
fetch wrapper, and the up-front root locate and relative-root anchoring
they needed. `Refused` is boxed on the load error, which it would
otherwise push past clippy's size threshold.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… lead where they lead

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.

🟡 Changes recommended

Path vetting accepts directories and FIFOs as modules or plugins, allowing a pre-built step with a FIFO and sha256 to block indefinitely during checksum reading.

1 open issue
5 resolved since last review

Review effort: Balanced

canister: &crate::Canister,
path: &Path,
) -> Result<(), Refused> {
let resolved = files.canonicalize(path).await.ok_or_else(|| {

This branch has not been deployed

No deployments
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