Skip to content

feat(sandbox): add user-configurable read-only paths#542

Open
EZotoff wants to merge 3 commits intospacedriveapp:mainfrom
EZotoff:feat/read-only-sandbox-paths
Open

feat(sandbox): add user-configurable read-only paths#542
EZotoff wants to merge 3 commits intospacedriveapp:mainfrom
EZotoff:feat/read-only-sandbox-paths

Conversation

@EZotoff
Copy link
Copy Markdown

@EZotoff EZotoff commented Apr 5, 2026

Summary

The sandbox currently supports writable_paths for full access, but there's no middle ground for read-only access to specific paths. This PR adds readable_paths to SandboxConfig for OS-enforced read-only mounts.

Changes

src/sandbox.rs

  • Add readable_paths: Vec<PathBuf> to SandboxConfig
  • Update is_path_allowed() to include readable paths
  • Add is_path_writable() method (returns false for read-only paths)
  • Add --ro-bind mounts for readable paths in bwrap builder (Linux)
  • Add (allow file-read*) SBPL rules for readable paths (macOS)
  • Include readable paths in prompt_read_allowlist()

src/tools/file.rs

  • Refactor resolve_path() into resolve_path_inner(raw, require_writable)
  • Add resolve_writable_path() that rejects write operations on read-only paths
  • FileWriteTool and FileEditTool now use resolve_writable_path()
  • Returns "ACCESS DENIED: This path is mounted as read-only" on write attempts

Config

[sandbox]
readable_paths = ["/data/reference", "/etc/config"]
writable_paths = ["/workspace"]

Behavior

Path type Read Write
readable_paths ❌ (OS-enforced)
writable_paths
Neither

Testing

  • LSP diagnostics clean on both modified files
  • Default readable_paths is empty — no behavior change for existing configs

Copilot AI review requested due to automatic review settings April 5, 2026 08:59
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 5, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7d9fe357-32d8-4934-88e8-a997d8105cbc

📥 Commits

Reviewing files that changed from the base of the PR and between fa48c54 and 795cec6.

📒 Files selected for processing (2)
  • src/sandbox.rs
  • src/tools/file.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/tools/file.rs
  • src/sandbox.rs

Walkthrough

Adds a readable_paths: Vec<PathBuf> to sandbox config, enforces read-only semantics for those paths during path checks and tool resolution, canonicalizes paths for comparisons, and updates bubblewrap and macOS SBPL generation to mount/allow-read-only those paths.

Changes

Cohort / File(s) Summary
Sandbox Path Enforcement
src/sandbox.rs
Added pub readable_paths: Vec<PathBuf> (serde default), canonicalize checks to exclude data_dir, updated is_path_allowed() to consider readable_paths, added pub fn is_path_writable(), updated shell allowlist and backend generation to mount/allow canonicalized read-only paths, and adjusted tests asserting read-only behavior.
File Path Validation & Tools
src/tools/file.rs
Introduced resolve_path_inner(raw, require_writable) and wrappers resolve_path/resolve_writable_path; updated file_read, file_write, and file_edit to use writable-resolution where appropriate; added test ensuring edits are blocked on readable_paths.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(sandbox): add user-configurable read-only paths' clearly and concisely summarizes the main change: introducing a new read-only paths feature to the sandbox configuration.
Description check ✅ Passed The description is directly related to the changeset, providing a clear summary of changes, configuration examples, behavior matrix, and testing notes that align with the modifications in sandbox.rs and file.rs.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a new “middle ground” sandbox permission tier by introducing readable_paths (read-only allowlist) alongside existing writable_paths, aiming for OS-enforced read-only mounts plus tool-level write rejection.

Changes:

  • Extend SandboxConfig with readable_paths and incorporate them into allowlist decisions.
  • Add is_path_writable() and wire read-only paths into Linux bubblewrap mounts and macOS SBPL generation.
  • Refactor file tool path resolution to support “require writable” validation for mutating operations.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
src/sandbox.rs Adds readable_paths, updates allowlist logic, and attempts OS-level enforcement via bwrap/SBPL.
src/tools/file.rs Refactors path resolution and introduces resolve_writable_path() for write enforcement (but has incorrect call-site usage).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
let path = self.context.resolve_path(&args.path)?;
let path = self.context.resolve_writable_path(&args.path)?;
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

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

file_read is resolving paths with resolve_writable_path(), which will reject reads from SandboxConfig.readable_paths (read-only mounts). file_read should allow reading from read-only paths and only require writability for write/edit operations.

Suggested change
let path = self.context.resolve_writable_path(&args.path)?;
let path = self.context.resolve_readable_path(&args.path)?;

Copilot uses AI. Check for mistakes.
src/sandbox.rs Outdated
for (index, path) in config.readable_paths.iter().enumerate() {
let canonical = canonicalize_or_self(path);
profile.push_str(&format!(
"; readable path {index}\n(allow file-read* (subpath \"{}\"))\n",
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

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

In the macOS SBPL profile generation, readable_paths only get a file-read* allow rule. If a readable_path overlaps with the workspace or any other writable allowlist, it will still be writable due to the broader file-write* allow rules. Add an explicit (deny file-write* (subpath ...)) for each readable path (similar to the existing data-dir deny) to make read-only mounts OS-enforced on macOS too.

Suggested change
"; readable path {index}\n(allow file-read* (subpath \"{}\"))\n",
"; readable path {index}\n(allow file-read* (subpath \"{}\"))\n(deny file-write* (subpath \"{}\"))\n",
escape_sbpl_path(&canonical),

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
src/sandbox.rs (1)

1059-1064: Please add behavioral tests for readable_paths.

This only checks that the new field defaults to empty. The important contract is the read/write split: readable-only roots should pass is_path_allowed(), fail is_path_writable(), and the file tools should mirror that behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sandbox.rs` around lines 1059 - 1064, Add behavioral assertions to the
SandboxConfig test to verify readable-only roots: in
test_sandbox_config_defaults (or a new test) create a SandboxConfig with a path
placed into SandboxConfig.readable_paths (and not in writable_paths), then
assert that is_path_allowed(path) returns true, is_path_writable(path) returns
false, and that the file access helpers used by the project (the file read tool
succeeds and the file write tool fails or returns permission error) mirror that
behavior; use existing symbols SandboxConfig, SandboxMode, readable_paths,
is_path_allowed(), and is_path_writable() to locate where to add these checks
and use temporary test files/dirs to exercise actual file-tool behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/sandbox.rs`:
- Around line 310-315: The readable-paths check in is_path_allowed currently
allows any prefix match from config.readable_paths; update the loop over
config.readable_paths (the allowed/canonical logic) to exclude matches that
would grant access into self.data_dir. Concretely, when iterating and computing
let allowed = path.canonicalize().unwrap_or_else(|_| path.clone()), only return
true if canonical.starts_with(&allowed) AND the candidate does not lie under
self.data_dir (e.g. !canonical.starts_with(&self.data_dir)) and also skip any
allowed that itself is inside self.data_dir (e.g.
!allowed.starts_with(&self.data_dir)); reference symbols: is_path_allowed,
config.readable_paths, self.data_dir, canonical, allowed.

In `@src/tools/file.rs`:
- Around line 335-336: The FileEditTool still calls resolve_path() leaving
readable_paths writable; update the FileEditTool call handler (the async fn call
implementation for FileEditTool) to use
context.resolve_writable_path(&args.path) instead of
context.resolve_path(&args.path), mirroring the change made in file_write;
ensure the returned Result propagates the same error when the path is not
writable so the read-only denial is enforced.
- Around line 228-229: The file_read tool's call method incorrectly uses
resolve_writable_path(&args.path), causing reads to fail when
Sandbox::is_path_writable() is false for readable-only roots; change the call in
the async fn call implementation to use resolve_path(&args.path) instead of
resolve_writable_path(&args.path) so reads use the read-only allowlist, and add
a regression test that exercises file_read against a readable-only root
(verifying resolve_path allows the read) to prevent future regressions.

---

Nitpick comments:
In `@src/sandbox.rs`:
- Around line 1059-1064: Add behavioral assertions to the SandboxConfig test to
verify readable-only roots: in test_sandbox_config_defaults (or a new test)
create a SandboxConfig with a path placed into SandboxConfig.readable_paths (and
not in writable_paths), then assert that is_path_allowed(path) returns true,
is_path_writable(path) returns false, and that the file access helpers used by
the project (the file read tool succeeds and the file write tool fails or
returns permission error) mirror that behavior; use existing symbols
SandboxConfig, SandboxMode, readable_paths, is_path_allowed(), and
is_path_writable() to locate where to add these checks and use temporary test
files/dirs to exercise actual file-tool behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8326fb84-9e40-4f05-baa5-f7c922142d3a

📥 Commits

Reviewing files that changed from the base of the PR and between fb5c0f3 and 1f83a68.

📒 Files selected for processing (2)
  • src/sandbox.rs
  • src/tools/file.rs

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
prompts/en/cortex.md.j2 (1)

52-65: Decision memory "fields" will be stored as unstructured text.

The prompt presents decided_by, decision, rationale, and alternatives as if they are structured fields, but the Memory schema only stores these in the content TEXT field. This means:

  1. The LLM must self-consistently format these attributes as prose/markdown in content.
  2. Memory consolidation and retrieval logic cannot query or filter by these "fields" programmatically.
  3. Future LLM calls parsing these memories may interpret the structure inconsistently.

Consider either:

  • Clarifying in the prompt that these should be formatted as a structured block in content (e.g., YAML/JSON snippet or markdown table), or
  • Extending the Memory schema to support typed decision metadata if this provenance data needs programmatic access.

The importance threshold (≥0.8) and association instructions (Updates, Contradicts) are valid—those fields/types exist in the schema.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@prompts/en/cortex.md.j2` around lines 52 - 65, The prompt describes Decision
memory fields (`decided_by`, `decision`, `rationale`, `alternatives`,
`importance`, and associations `Updates`/`Contradicts`) as if they are
structured, but the Memory schema only stores TEXT in the `content` field;
update the prompt text to require the LLM to render these attributes as a
consistent structured block inside `content` (e.g., explicit YAML or JSON
snippet or a fixed markdown section) and mention that retrieval cannot
programmatically query separate columns, or alternatively note that the Memory
schema must be extended to add typed decision metadata if programmatic filtering
by `importance` or `decided_by` is required—include both options succinctly so
implementers know to either enforce a serialized format in `content` or change
the `Memory` schema.
src/sandbox.rs (1)

328-330: Redundant condition check.

At this point, line 305-307 already returned false if canonical.starts_with(&data_dir_canonical). Code reaching line 329 is guaranteed to have !canonical.starts_with(&data_dir_canonical) evaluate to true.

♻️ Simplify to direct return
         if canonical.starts_with(&allowed) {
-            return !canonical.starts_with(&data_dir_canonical);
+            return true;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sandbox.rs` around lines 328 - 330, The conditional currently does `if
canonical.starts_with(&allowed) { return
!canonical.starts_with(&data_dir_canonical); }` but earlier code already
returned false when `canonical.starts_with(&data_dir_canonical)`, so that
negation is redundant; change the block that checks
`canonical.starts_with(&allowed)` (in the function containing `canonical`,
`allowed`, and `data_dir_canonical`) to simply return true (or just `return
true`), removing the extra `starts_with(&data_dir_canonical)` check.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@prompts/en/cortex.md.j2`:
- Around line 52-65: The prompt describes Decision memory fields (`decided_by`,
`decision`, `rationale`, `alternatives`, `importance`, and associations
`Updates`/`Contradicts`) as if they are structured, but the Memory schema only
stores TEXT in the `content` field; update the prompt text to require the LLM to
render these attributes as a consistent structured block inside `content` (e.g.,
explicit YAML or JSON snippet or a fixed markdown section) and mention that
retrieval cannot programmatically query separate columns, or alternatively note
that the Memory schema must be extended to add typed decision metadata if
programmatic filtering by `importance` or `decided_by` is required—include both
options succinctly so implementers know to either enforce a serialized format in
`content` or change the `Memory` schema.

In `@src/sandbox.rs`:
- Around line 328-330: The conditional currently does `if
canonical.starts_with(&allowed) { return
!canonical.starts_with(&data_dir_canonical); }` but earlier code already
returned false when `canonical.starts_with(&data_dir_canonical)`, so that
negation is redundant; change the block that checks
`canonical.starts_with(&allowed)` (in the function containing `canonical`,
`allowed`, and `data_dir_canonical`) to simply return true (or just `return
true`), removing the extra `starts_with(&data_dir_canonical)` check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1bcb1405-e83d-4658-a51a-ad41bc5c6643

📥 Commits

Reviewing files that changed from the base of the PR and between c69cfcd and bf37ec1.

📒 Files selected for processing (3)
  • prompts/en/cortex.md.j2
  • src/sandbox.rs
  • src/tools/file.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/tools/file.rs

EZotoff added 2 commits April 6, 2026 00:21
Add readable_paths to SandboxConfig for OS-enforced read-only mounts.
Workers can read from these paths but cannot write or edit files there.

Uses --ro-bind in bwrap (Linux) and SBPL allow file-read* rules (macOS).
FileWriteTool and FileEditTool now use resolve_writable_path() which
rejects write operations on read-only paths.
…ution, data_dir exclusion, macOS SBPL deny rules
@EZotoff EZotoff force-pushed the feat/read-only-sandbox-paths branch from bf37ec1 to fa48c54 Compare April 5, 2026 22:46
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
src/tools/file.rs (2)

228-229: ⚠️ Potential issue | 🔴 Critical

Don't gate file_read on writability.

Sandbox::is_path_writable() is false for every readable_paths prefix, so Line 229 blocks the exact read-only roots this PR adds. file_read should resolve through resolve_path(), with a regression test for reading from a readable-only root.

Suggested fix
-        let path = self.context.resolve_writable_path(&args.path)?;
+        let path = self.context.resolve_path(&args.path)?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/file.rs` around lines 228 - 229, The call implementation for
file_read is using context.resolve_writable_path(&args.path) which incorrectly
rejects readable-only roots; change it to use context.resolve_path(&args.path)
(or the equivalent non-writable resolver) inside async fn call so reads are
allowed on readable_paths, and add a regression test that creates a
readable-only root and verifies file_read can read a file under that prefix;
update any references to resolve_writable_path in the file_read code path to
resolve_path and ensure error handling remains consistent.

335-336: ⚠️ Potential issue | 🔴 Critical

Mirror this writable-path guard in file_edit.

FileEditTool still calls resolve_path() at Line 421, so edits against readable_paths bypass the new read-only denial. Switch that call to resolve_writable_path() too, and add a regression test that exercises file_edit on a readable-only root.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/file.rs` around lines 335 - 336, FileEditTool still uses
resolve_path which allows edits on read-only roots; change the call in
FileEditTool (the async call implementation that currently invokes resolve_path
for args.path) to use context.resolve_writable_path(&args.path) instead, and add
a regression test that invokes the file_edit tool against a readable-only root
to assert the operation is denied; reference the FileEditTool async call method,
the resolve_path → resolve_writable_path replacement, and the file_edit behavior
in the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/tools/file.rs`:
- Around line 228-229: The call implementation for file_read is using
context.resolve_writable_path(&args.path) which incorrectly rejects
readable-only roots; change it to use context.resolve_path(&args.path) (or the
equivalent non-writable resolver) inside async fn call so reads are allowed on
readable_paths, and add a regression test that creates a readable-only root and
verifies file_read can read a file under that prefix; update any references to
resolve_writable_path in the file_read code path to resolve_path and ensure
error handling remains consistent.
- Around line 335-336: FileEditTool still uses resolve_path which allows edits
on read-only roots; change the call in FileEditTool (the async call
implementation that currently invokes resolve_path for args.path) to use
context.resolve_writable_path(&args.path) instead, and add a regression test
that invokes the file_edit tool against a readable-only root to assert the
operation is denied; reference the FileEditTool async call method, the
resolve_path → resolve_writable_path replacement, and the file_edit behavior in
the test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fb6880a2-2ad4-48c6-bbab-596a62c62c5a

📥 Commits

Reviewing files that changed from the base of the PR and between bf37ec1 and fa48c54.

📒 Files selected for processing (2)
  • src/sandbox.rs
  • src/tools/file.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/sandbox.rs

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.

2 participants