fix(workspace): allow named workspaces in read-side path resolution#3137
Merged
houko merged 2 commits intoApr 25, 2026
Conversation
PR librefang#2958 wired named workspaces (`[workspaces]` in agent.toml) into write-side denial only — `file_read`, `file_list`, and `apply_patch` still rejected any absolute path that did not resolve under the agent's primary workspace, even when the path pointed at a declared `rw` named workspace surfaced to the model via TOOLS.md. Surface the full named-workspace allowlist (with modes) on `KernelHandle` via a new `named_workspace_prefixes` method and rebuild `readonly_workspace_prefixes` as a thin filter on top so the canonical resolution lives in one place. Extend `workspace_sandbox::resolve_sandbox_path` with an `additional_roots` slice (`resolve_sandbox_path_ext`); the original two-arg helper is preserved as a wrapper for back-compat. Relative paths still join the primary workspace root — named workspaces are addressed by their absolute path. `..` rejection and symlink-escape protection are unchanged: a symlink whose target leaves every allowed root is still denied because the canonicalized candidate no longer starts with any prefix. Thread the prefixes through the `tool_runner` dispatch arms for `file_read`, `file_list`, `apply_patch` (all named workspaces), and `file_write` (rw-only — the existing read-only denial check still runs first). `apply_patch` gains the additional-roots parameter end-to-end. Tests: - 5 new unit tests in `workspace_sandbox` covering the allowed/denied matrix plus `..` and symlink rejection with additional roots present. - 5 new integration tests in `tool_runner` exercising `file_read`, `file_list`, `file_write` (rw + ro), and the negative case where a path lives outside both the primary and named workspaces.
houko
previously approved these changes
Apr 25, 2026
houko
left a comment
Contributor
There was a problem hiding this comment.
LGTM. Optional follow-ups (non-blocking): cache canonical paths in the kernel impl; add named-workspace integration test for apply_patch; sharpen the sandbox denial message to mention named workspaces.
- Replace .iter().filter_map(|(_, decl)| ...) with .values().filter_map to satisfy clippy::iter_kv_map (CI Quality job failure on PR librefang#3137). - Sharpen sandbox denial message to mention [workspaces] / TOOLS.md named-workspace roots so an agent that hits the wall has a concrete next step instead of being pointed at MCP filesystem only. - Add apply_patch named-workspace integration tests (rw allowed, r-only denied) — parity with the file_read/file_write coverage.
This was referenced Apr 25, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PR #2958 added named workspaces (
[workspaces]inagent.toml/HAND.tomlagent sections) and wired them into write-side denial —tool_runner.rs:419–434checkskernel.readonly_workspace_prefixes()to reject writes into read-only named workspaces. But the corresponding read-side allowance was never wired:file_read,file_list, andapply_patchgo throughworkspace_sandbox::resolve_sandbox_pathwhich only checks the canonical primary workspace root.Result: an agent that legitimately declares
foo = { path = "shared/foo", mode = "rw" }and learns the absolute path from the auto-generatedTOOLS.mdline@foo → /abs/path/shared/foo (read-write)canshell_exec cat <path>against it (no sandbox check on shell), butfile_read({"path": "<same path>"})returns:This PR closes the asymmetry.
Why no test caught it
crates/librefang-runtime/src/workspace_sandbox.rstests all use a single workspace root. There's no positive case asserting that read tools succeed against a declared named workspace path. PR #2958's review feedback caught the write-side denial gap (the "address code review issues" commit addedfile_writeenforcement), but no one noticed reads were also broken because no test asserted positive read-allowed behavior on named workspaces.Design
KernelHandle::named_workspace_prefixes(agent_id) -> Vec<(PathBuf, WorkspaceMode)>— new method returning all canonical absolute paths with their modes. The kernel impl canonicalizes once.readonly_workspace_prefixesbecomes a thin filter on top so canonical resolution isn't duplicated.workspace_sandbox::resolve_sandbox_path_ext(user_path, workspace_root, additional_roots: &[&Path])— the existing two-argresolve_sandbox_pathis preserved as a wrapper. The new function widens acceptance toinside_primary || inside_any_additional...rejection and the symlink-escape predicate are unchanged. Relative paths still join withworkspace_rootonly — additional roots are absolute-only entry points.Tool dispatch wires the prefixes:
file_read/file_list/apply_patch→ all named-workspace prefixes (rw and r both allowed for reads).file_write→ rw-filtered prefixes only. The existing read-only denial check (readonly_workspace_prefixes) still runs before path resolution, preserving the spec'd behavior of a clear "Write denied: read-only named workspace" error message rather than a generic sandbox rejection.The leaf functions (
tool_file_read/write/list,apply_patch) gained anadditional_roots: &[&Path]parameter; two file-private helpers intool_runner.rs(named_ws_prefixes,named_ws_prefixes_writable) build the slice from the kernel handle.Files changed
crates/librefang-kernel-handle/src/lib.rsnamed_workspace_prefixes()returning emptycrates/librefang-kernel/src/kernel/mod.rsnamed_workspace_prefixes; refactorsreadonly_workspace_prefixesas a filter on topcrates/librefang-runtime/src/workspace_sandbox.rsresolve_sandbox_path_ext; existing fn becomes a wrapper. New unit tests.crates/librefang-runtime/src/apply_patch.rsapply_patchandresolve_patch_pathacceptadditional_rootscrates/librefang-runtime/src/tool_runner.rsTests
workspace_sandbox.rs— went from 7 → 12 unit tests:test_relative_path_inside_primary_workspace_with_additional— relative still joins primarytest_absolute_path_inside_additional_root_allowed— absolute under an additional root succeedstest_absolute_path_outside_all_roots_blocked— neither primary nor additional → deniedtest_dotdot_still_blocked_with_additional_roots—..still rejectedtest_symlink_escape_still_blocked_via_additional_root— symlink escape from additional root deniedtool_runner.rs— 5 new integration tests using a smallNamedWsKernelmock that drivesnamed_workspace_prefixesdirectly. Covers: file_read against rw, file_read against r, file_list, file_write against rw, file_write against r returns the existing read-only denial.Verification
cargo test --workspace --libshows 9 pre-existing failures (config::tests::test_load_config_defaults, 8cron_deliverytests rejecting loopback URLs / absolute paths). Verified independently onupstream/main— same 9 failures. Unrelated to this change.Note on a small extension to the design
The non-existent-parent rebase branch in
resolve_sandbox_path_ext(the path that supports writing new files whose parent dir doesn't exist yet) needed to be extended: when the candidate is absolute and lives under an additional root, the file-doesn't-yet-exist path rebases onto the additional root rather than the primary canon root. Without that,file_writeof a new file inside a named workspace wouldstarts_with-fail. This preserves the symlink-handling rationale of the original code and is exercised by the integration tests.Risk / blast radius
Low. Read-side tools only widen acceptance; no path that was previously allowed becomes denied. Write-side denial preserved. The
KernelHandlegets one new default-impl method, so any third-partyKernelHandleimplementor compiles unchanged.Repro before this PR
After this PR: both succeed.
file_writeagainst anr-mode named workspace continues to return the existing "Write denied" error.Closes the read-side half of the contract introduced by #2958.