Skip to content

fix(zen): confine load targets to the workspace and its dependencies - #1559

Merged
LK merged 2 commits into
mainfrom
fix/zen-load-containment
Oct 24, 2026
Merged

LK merged 2 commits into
mainfrom
fix/zen-load-containment

Conversation

@LK

@LK LK commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Why

load(), File(), and Path() could resolve to files anywhere on the host through absolute paths, @stdlib//abs/path, package:// URIs with .., or symlinks. When such a file failed to parse, the diagnostic echoed its contents.

Changes

  • resolve() now canonicalizes every resolved target and rejects it unless it lies inside the workspace, its stdlib, or a resolved dependency (including [patch] path deps).
  • The check runs before the existence check, so a rejected path doesn't reveal whether the file exists.

Validation

  • New test load_cannot_read_files_outside_workspace covers absolute paths, @stdlib//abs, package:// traversal, and symlinks, and checks that in-workspace absolute loads still work. It fails without the fix.
  • Relative loads, @stdlib/..., and an out-of-workspace [patch] path dependency still load.
  • cargo nextest run --workspace passes; clippy is clean.

Note

High Risk
Security fix in core file-resolution for Zen evaluation; behavior change for any project that relied on out-of-workspace paths, but intended tightening with broad test coverage.

Overview
Closes a path where load(), File(), and Path() could read arbitrary host files via absolute paths, @stdlib abuse, package:// traversal, or symlinks—and where parse failures could leak file contents in diagnostics.

resolve() now canonicalizes every resolved target and rejects it unless it lies under the workspace root or a resolved package (stdlib and deps included), via new contains_path. The check runs before the existence/read path so rejected specs do not confirm whether a file exists.

Docs and changelog describe the rule; a Unix integration test covers the main escape vectors and confirms in-workspace absolute loads still work.

Reviewed by Cursor Bugbot for commit 6cca407. Bugbot is set up for automated code reviews on this repo. Configure here.

Absolute paths, package:// URIs, @stdlib paths with absolute segments, and
symlinks could resolve to any host file, and parse diagnostics then echoed
its contents. resolve() is the one point every load, File(), and Path()
passes through, so canonicalize the target there and reject it unless it
lies in the workspace, its stdlib, or a resolved package root.

Amp-Thread-ID: https://ampcode.com/threads/T-67f94b14-128e-4596-b9ab-2be59ef8d30a
Co-authored-by: Amp <amp@ampcode.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

Devin Review

Comment on lines +914 to +918
let canonical = context.file_provider.canonicalize(&resolved_path)?;
if !self
.resolution
.contains_path(context.file_provider, &canonical)
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟥 Retargeted symlink leaks host files

After resolve() caches a safe symlink target, retargeting that symlink outside the workspace leaves containment using the old path. read_file() follows its new target and can return host file contents in diagnostics.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not changing this. Exploiting it needs the symlink retargeted mid-evaluation, after its first canonicalization. A build evaluates a fixed checkout in one process, and Zen code has no way to create or retarget symlinks. In the long-lived LSP only the local user can change their own symlinks, and they can already read those files. Re-checking on every read would add cost without closing a reachable path.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 17f850b. Configure here.

.resolution
.values()
.any(|map| map.package_for_file(path).is_some())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Empty root rejects every load

Medium Severity

contains_path treats an empty workspace root as contained-in-nothing, then the new resolve() gate rejects every spec. ResolutionResult::empty() uses that empty root, including the LSP fallback when dependency resolution fails, so prelude @stdlib loads and other resolve() calls now fail instead of degrading.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 17f850b. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Keeping this. ResolutionResult::empty() has no package scopes, so relative, @stdlib, and package-URL loads already failed in that fallback with "current file not in any package". Only absolute paths and package:// URIs got through. With no workspace to contain them, rejecting those too is the intended behavior.

Comment thread crates/pcb-zen-core/src/resolution.rs Outdated
The frozen resolution already lists the canonical stdlib root, captured
right after pcb relinks .pcb/stdlib to the toolchain, so the separate
stdlib prefix check was redundant.

Amp-Thread-ID: https://ampcode.com/threads/T-67f94b14-128e-4596-b9ab-2be59ef8d30a
Co-authored-by: Amp <amp@ampcode.com>
@LK
LK merged commit 5f85dca into main Oct 24, 2026
13 checks passed
@LK
LK deleted the fix/zen-load-containment branch October 24, 2026 02:05
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.

1 participant