Skip to content

feat(shell): fold the selected session directory into the jail roots - #375

Closed
ytallo wants to merge 2 commits into
mainfrom
fix/shell-host-roots-selected-dirs
Closed

feat(shell): fold the selected session directory into the jail roots#375
ytallo wants to merge 2 commits into
mainfrom
fix/shell-host-roots-selected-dirs

Conversation

@ytallo

@ytallo ytallo commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Problem

A console-selected working directory arrives at the shell worker as the
per-call base_dir, but the coder jail only permitted paths inside the
statically configured host_roots. Any selected directory outside them was
rejected with C215:

path is outside every allowed root: /Users/.../workers.
Allowed roots: /private/tmp.

So the working-directory picker could only ever scope a chat to a directory
that was already baked into the jail config — selecting a real project path
failed.

Fix

PathResolver::session_scoped folds the selected base_dir into the
effective allowed roots for that call (via with_session_root), so every
downstream check treats it as a first-class root:

  • containment in resolve / resolve_in
  • the non-accessible denylist (which relativises through containing_root)
  • is_root and session_root

It is a no-op when base_dir is absent, already inside a configured root
(only an Arc bump), or cannot be canonicalised (the handler's own
resolve_in then produces the precise error). Wired into every
base_dir-bearing coder dispatch closure (read / search / update / create /
delete / list / tree / move).

Why this is safe to widen on

  • base_dir is stamped by the harness control plane (workspace injection),
    never by the model, so only operator-chosen directories grow the jail.
  • resolve_in still scopes access to the session directory.
  • The non-accessible denylist still applies, because the selected directory
    is now a real root that containing_root can relativise against.

Test plan

  • cargo build — clean, 0 warnings
  • cargo test — green; added session_scoped_adds_selected_dir_outside_jail
    covering: outside-jail dir rejected until added, reachable once added,
    **/.env still blocked under the added root, and no-op for an in-jail /
    absent base_dir.

Follow-ups (out of scope)

  • The selection is per-call and does not persist into shell/config.yaml.
  • The console working-directory picker is still unconstrained by host_roots.

Summary by CodeRabbit

  • New Features

    • File and folder actions now respect the selected working directory for each request, making operations more consistent with the current session context.
  • Bug Fixes

    • Improved path handling so valid session directories can be accessed even when they fall outside the default allowed roots.
    • Existing restrictions still apply, including blocked sensitive files and folders.

A console-selected working directory arrives as the per-call base_dir, but
the coder jail only permitted paths inside the statically configured
host_roots — so any selected directory outside them was rejected with C215
("path is outside every allowed root").

PathResolver::session_scoped adds the selected base_dir into the effective
allowed roots for that call (via with_session_root), so every downstream
check treats it as a first-class root: containment in resolve/resolve_in,
the non-accessible denylist (which relativises through containing_root),
is_root, and session_root. It is a no-op when base_dir is absent, already
inside a configured root, or uncanonicalizable.

Safe to widen on: base_dir is stamped by the harness control plane, never
the model, so only operator-chosen directories grow the jail — and
resolve_in still scopes access to the session directory. Wired into every
base_dir-bearing coder dispatch closure.
@vercel

vercel Bot commented Jun 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
workers Ready Ready Preview, Comment Jun 30, 2026 1:04am
workers-tech-spec Ready Ready Preview, Comment Jun 30, 2026 1:04am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

skill-check — worker

0 verified, 26 skipped (no docs/).

Layer Result
structure
vale
ai
render

Four for four. Nicely done.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ytallo, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 35 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ef08c192-e321-4c1a-8668-9e30ec321fde

📥 Commits

Reviewing files that changed from the base of the PR and between 16779d8 and d5fdb28.

📒 Files selected for processing (1)
  • shell/src/code/functions/mod.rs
📝 Walkthrough

Walkthrough

PathResolver gains a session_scoped method that widens its allowed-roots jail to include a request's base_dir when that directory canonicalizes outside every existing root. All eight filesystem verb handlers (read_file, search, update_file, create_file, delete_file, list_folder, tree, move_file) now call this method inside their async blocks before dispatching.

Changes

Session-Scoped Path Resolution

Layer / File(s) Summary
PathResolver::session_scoped implementation and tests
shell/src/code/path.rs
Adds Arc import, private with_session_root(base_canon: PathBuf) -> Self, and public session_scoped(self: &Arc<Self>, base_dir: Option<&str>) -> Arc<Self>. Returns an unchanged clone when base_dir is absent, already inside the jail, or non-canonicalizable; otherwise creates a new resolver with the canonicalized path appended to allowed roots. Test session_scoped_adds_selected_dir_outside_jail covers widening, denylist enforcement, and no-op paths.
Verb handler wiring
shell/src/code/functions/mod.rs
All eight handlers (register_read_file, register_search, register_update_file, register_create_file, register_delete_file, register_list_folder, register_tree, register_move_file) now derive a session-scoped resolver from req.base_dir inside the async block before calling the corresponding handle.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 A jail with a door that opens just right,
For the session's chosen dir, we widen the sight.
Eight handlers now scope before they proceed,
The denylist still guards each .env freed.
Hop hop, the roots expand, then snap back tight! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: scoping the selected session directory into the effective jail roots.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/shell-host-roots-selected-dirs

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.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
shell/src/code/path.rs (1)

786-828: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: add a no-op assertion for a non-canonicalizable base_dir.

The test covers outside-jail widening, denylist enforcement, inside-jail no-op, and None no-op, but not the third documented no-op (lines 472-474): a base_dir that cannot be canonicalized should return the resolver unchanged so the handler's own resolve_in produces the precise error. A one-line assert pins that branch.

💚 Suggested addition
// A base_dir that cannot be canonicalized is a no-op (the handler's own
// resolve_in then surfaces the precise error).
assert_eq!(
    r.session_scoped(Some("/this/does/not/exist/xyz123")).roots().len(),
    r.roots().len()
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shell/src/code/path.rs` around lines 786 - 828, Add a no-op assertion for the
non-canonicalizable base_dir branch in session_scoped. The current test in
PathResolver::session_scoped covers outside-jail widening, denylist behavior,
inside-root no-op, and None no-op, but not the case where the selected base_dir
cannot be canonicalized. Add an assertion that a clearly invalid path leaves the
resolver unchanged so resolve_in can surface the precise error from the handler.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@shell/src/code/path.rs`:
- Around line 485-496: Treat the session_scoped(base_dir) input as untrusted and
do not use caller-provided request data to widen the jail. Update session_scoped
in Path so it only derives the session root from server-side/control-plane
metadata, or explicitly reject non-control-plane base_dir values before calling
canonicalize_wire, containing_root, or with_session_root; keep the clone-only
behavior for untrusted/absent inputs.

---

Nitpick comments:
In `@shell/src/code/path.rs`:
- Around line 786-828: Add a no-op assertion for the non-canonicalizable
base_dir branch in session_scoped. The current test in
PathResolver::session_scoped covers outside-jail widening, denylist behavior,
inside-root no-op, and None no-op, but not the case where the selected base_dir
cannot be canonicalized. Add an assertion that a clearly invalid path leaves the
resolver unchanged so resolve_in can surface the precise error from the handler.
🪄 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: 97e63f65-9fba-4b6c-bace-9e441d5c2b53

📥 Commits

Reviewing files that changed from the base of the PR and between 9168307 and 16779d8.

📒 Files selected for processing (2)
  • shell/src/code/functions/mod.rs
  • shell/src/code/path.rs

Comment thread shell/src/code/path.rs
Comment on lines +485 to +496
pub fn session_scoped(self: &Arc<Self>, base_dir: Option<&str>) -> Arc<Self> {
let Some(bd) = base_dir else {
return self.clone();
};
let Ok(base_canon) = self.canonicalize_wire(bd, Path::new(bd)) else {
return self.clone();
};
if self.containing_root(&base_canon).is_some() {
return self.clone();
}
Arc::new(self.with_session_root(base_canon))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 1. Where is base_dir defined on the input structs and how is it deserialized?
rg -nP -C3 '\bbase_dir\b' --type=rust shell/src/code/functions | rg -nP -C3 '(struct|serde|deserialize|Deserialize|Option<String>)'

# 2. Find the input struct definitions carrying base_dir.
ast-grep run --pattern 'struct $S { $$$ base_dir: $T, $$$ }' --lang rust shell/src

# 3. Is base_dir ever overwritten/injected server-side (control plane / workspace injection)
#    rather than taken verbatim from the deserialized model request?
rg -nP -C3 '\bbase_dir\s*[:=]' --type=rust shell/src | rg -nvP '//'

# 4. How requests reach register_function / new_async — is the arg payload model-authored?
rg -nP -C4 'new_async|register_function' --type=rust shell/src/code/functions/mod.rs

Repository: iii-hq/workers

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== functions/mod.rs outline =="
ast-grep outline shell/src/code/functions/mod.rs --view expanded | sed -n '1,220p'

echo
echo "== functions/types.rs outline =="
ast-grep outline shell/src/functions/types.rs --view expanded | sed -n '1,260p'

echo
echo "== request structs with base_dir in code/functions =="
rg -n -C4 'pub base_dir: Option<String>|base_dir: req\.base_dir\.as_deref\(\)|base_dir:' shell/src/code/functions/*.rs shell/src/functions/types.rs

echo
echo "== registration / schema plumbing in functions/mod.rs =="
sed -n '1,260p' shell/src/code/functions/mod.rs

echo
echo "== top of functions/types.rs =="
sed -n '1,240p' shell/src/functions/types.rs

Repository: iii-hq/workers

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== shell/src/code/functions/mod.rs around register_all =="
sed -n '220,460p' shell/src/code/functions/mod.rs

echo
echo "== shell/src/code/functions/mod.rs around catalog/spec =="
sed -n '170,240p' shell/src/code/functions/mod.rs

echo
echo "== all use sites of req.base_dir.as_deref() in code/functions =="
rg -n -C2 'req\.base_dir\.as_deref\(\)' shell/src/code/functions

echo
echo "== all JsonSchema/Deserialize request structs with base_dir =="
rg -n -C3 '#\[derive\([^]]*Deserialize[^]]*\)' shell/src/code/functions/*.rs shell/src/functions/types.rs | sed -n '1,220p'

Repository: iii-hq/workers

Length of output: 29640


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== shell/src/code/path.rs around session root logic =="
sed -n '430,540p' shell/src/code/path.rs

echo
echo "== shell/src/code/path.rs around resolve_in / require_writable_in =="
sed -n '340,430p' shell/src/code/path.rs

echo
echo "== shell/src/code/path.rs around canonicalize_wire / containing_root =="
sed -n '140,220p' shell/src/code/path.rs

Repository: iii-hq/workers

Length of output: 12784


Treat base_dir as untrusted input before widening the jail. base_dir is part of the deserialized tool request schema and is passed straight into session_scoped(), so the allowed roots expand from caller-supplied data. Move it to server-side metadata or reject non-control-plane values before cloning the resolver.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shell/src/code/path.rs` around lines 485 - 496, Treat the
session_scoped(base_dir) input as untrusted and do not use caller-provided
request data to widen the jail. Update session_scoped in Path so it only derives
the session root from server-side/control-plane metadata, or explicitly reject
non-control-plane base_dir values before calling canonicalize_wire,
containing_root, or with_session_root; keep the clone-only behavior for
untrusted/absent inputs.

…g macro

The per-verb register_* fns repeated the same closure boilerplate — and the
`resolver.session_scoped(req.base_dir.as_deref())` line in particular — eight
times. Replace them with one `register_scoped!` macro (plus a no-config
variant) so the base_dir folding is written ONCE and each verb is a single
declarative line in register_all. Wire surface, schemas, and the
register_all/catalog drift guard are unchanged.
@ytallo

ytallo commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favor of #376, which implements the workspace picker/control-plane solution independently.

@ytallo ytallo closed this Jun 30, 2026
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