security(claude): sandbox UI-bridge tool paths to jupyter_root#323
Merged
Conversation
Moves the path-containment helper from built_in_toolsets.py (where it was the private `_get_safe_path`) to util.py as the public `safe_jupyter_path`, next to `get_jupyter_root_dir()`. Body is unchanged. Motivation: the Claude-mode UI-bridge tools in claude.py (`run-command-in-jupyter-terminal`, `open-file-in-jupyter-ui`) have the same security need (gate an LLM-supplied path before forwarding to a JupyterLab command), but importing the helper from built_in_toolsets creates an import cycle. Util has no NBI imports, so it's a clean home. built_in_toolsets keeps a private `_get_safe_path = safe_jupyter_path` alias so the existing internal call sites (file ops, search, dir listing) read identically. New code outside this module calls the public name.
PR plmbr#290 closed the working_directory traversal on the two builtin shell tools but left the two Claude-mode siblings open: - `run-command-in-jupyter-terminal` at claude.py:1071 forwards `working_directory` straight to the JupyterLab UI command `notebook-intelligence:run-command-in-terminal`, which passes it to `terminal:create-new` as `cwd`. An LLM tool call with `working_directory='/etc'`, `'../../..'`, or a workspace symlink pointing outside opens a terminal there and runs the command. - `open-file-in-jupyter-ui` at claude.py:1089 forwards `file_path` to `docmanager:open`. JupyterLab's contents service today rejects out-of-root absolute paths, but relying on that framework behavior leaves the same shape of bug latent if the contents service ever loosens; defense in depth applies the same gate here. Both tools now route the LLM-supplied path through `util.safe_jupyter_path`, the same gate the builtin tools use. The forwarded payload carries the resolved absolute path so any intermediate that does its own cwd-relative resolution can't double-resolve into a different target. Error wording matches the builtin siblings ("outside allowed directory", "does not exist", "not a directory") so the chat UX is consistent.
16 cases mirroring tests/test_builtin_toolset_cwd_sandbox.py from PR plmbr#290 but driving the Claude-mode tool handlers directly. run_command_in_jupyter_terminal: absolute escape, relative traversal, workspace symlink pointing outside, traversal via a valid-subdir prefix (`valid/../../..`, pins that resolve() collapses `..` before the containment check), embedded NUL, the two cwd-must-exist branches (nonexistent dir, file-not-dir), and positive cases for relative subdir, `.`, and `""` meaning the root. Every rejection asserts that the response spy's `run_ui_command` was never invoked, so a regression flunks the test rather than silently allowing the escape. open_file_in_jupyter_ui: absolute escape, relative traversal, workspace symlink, embedded NUL, and two positive cases — including a not-yet-existing file, since docmanager:open is also used for paths the user is about to create and the security gate deliberately doesn't require existence. Verified the new tests fail without the fix by reverting claude.py and re-running; the representative rejection cases flunk, then pass once the fix is reapplied.
Three reviewers converged on the docmanager regression; two on the
MCP is_error flag; test-arch and code-quality on lesser items.
All landed.
1. docmanager:open requires a workspace-relative path, not an
absolute one. The first revision forwarded str(target), but
JupyterLab's contents service strips the leading slash and
rejoins under root_dir (jupyter_server.utils.to_os_path), so
/workspace/foo.ipynb became {root}/workspace/foo.ipynb and 404'd
every open-file call. open_file_in_jupyter_ui now forwards
target.relative_to(root_dir).as_posix() instead. The terminal
sibling correctly stays on absolute paths because
terminal:create-new hands cwd to a real PTY spawn that honors
absolutes.
2. tool_text_response gains an is_error keyword that maps to MCP's
isError flag, which the Claude Agent SDK reads at
result.get("is_error", False) -> CallToolResult.isError. Every
rejection path in both tools now sets is_error=True so the
model treats sandbox violations as faults to recover from
rather than as authoritative output. Happy-path returns are
unchanged.
3. safe_jupyter_path raises RuntimeError (not ValueError) for the
"workspace root is not set" case. The tools' except ValueError
blocks surface a sandbox-violation message to the LLM; without
the distinction, a server-side misconfig would get reflected to
the model as if the LLM had picked a bad path. RuntimeError
falls through to the outer except Exception with explicit
"not set" wording so ops can tell the two apart.
4. The test file's jupyter_root and response_spy fixtures move
from yield/teardown to monkeypatch.setattr so an exception
mid-test doesn't leak _jupyter_root_dir / _current_response to
the next test. Matches the sibling pattern in
test_builtin_toolset_cwd_sandbox.py.
New tests:
- TestMCPErrorSignalling (4 cases) pins the is_error flag on
rejections and its absence on happy-path returns.
- TestRootNotSet (1 case) pins the RuntimeError-not-ValueError
contract.
- TestOpenFileInJupyterUiSandbox.test_allows_nested_path_relative_to_root
pins the relative-POSIX forwarding shape on a deeper path.
- The two existing positive open-file cases were asserting the
absolute path; updated to assert the relative form, which is
what JupyterLab actually accepts.
Deferred follow-up items (none security-relevant):
- Rename the 8 in-tree call sites of _get_safe_path to
safe_jupyter_path so the compat alias can drop.
- Align execute_command's sandbox-violation wording with the two
other path-bearing tools (the prefix differs by a few words).
- Relativize the jupyter_root_dir absolute path out of error text
for hosted-deployment safety.
- Windows hardlink caveat for the gate documentation.
- Three additional test cases (URI-encoded traversal, whitespace
paths, very long paths) and one ordering test
(safe_jupyter_path called before get_current_response).
mbektas
approved these changes
May 19, 2026
pjdoland
added a commit
to pjdoland/notebook-intelligence
that referenced
this pull request
May 22, 2026
Promotes the [Unreleased] CHANGELOG snapshot to [5.0.0] - 2026-05-22 and expands it to cover everything merged into upstream/main after PR plmbr#287's docs refresh. Bumps package.json to 5.0.0. CHANGELOG additions cover the post-plmbr#287 surface: - Settings tabs: plugin marketplace picker (plmbr#284), plugin marketplace details + Update button (plmbr#303), per-workspace MCP disable (plmbr#286), JSON-paste path in Add MCP server (plmbr#285). - Launchers: hide-with-policy (plmbr#288), brand icons for Codex / opencode (plmbr#325, plmbr#333), per-launch directory picker (plmbr#332). - Chat sidebar and agentic UX: workspace @-mention in Claude mode (plmbr#327), reload-open-files-on-disk (plmbr#330), steered system prompt away from over-eager notebook creation (plmbr#336). - Skills: multi-manifest support (plmbr#321), tracks-upstream for user- imported skills (plmbr#322), HTTP kill switch for the reconciler (plmbr#291). - Accessibility: full sub-section covering plmbr#305-plmbr#320. - Security: shell-tool sandbox (plmbr#290), Claude UI-bridge sandbox (plmbr#323), 0o600 on encrypted token (plmbr#293), env-secret scrubbing (plmbr#295), MCP config shape validation (plmbr#299), XSS allowlist (plmbr#296), Copilot WS auth + origin (plmbr#301), GHE host detection (plmbr#292), fastmcp -> mcp SDK swap (plmbr#324). - Fixed: session listing unification (plmbr#310), session preview unwrap (plmbr#331), down-area runtime throw (plmbr#330 follow-up), WS message-handler leak (plmbr#294). - Removed: fastmcp dependency, history.jsonl session gate. Adds a Migration note covering the five behavior changes operators should review before upgrading from 4.x: fastmcp swap, path sandboxes, history.jsonl gate removal, workspace @-mention pointer shape, and the Copilot WebSocket auth/origin tightening. Two reviewer rounds (six personas each) applied: - Round 1 caught security overclaims (plmbr#293, plmbr#299, plmbr#323), the plmbr#284/plmbr#303 mis-attribution, missing migration note, 3 em dashes, and the stale `fastmcp==2.x.*` recommendation in the admin guide. - Round 2 caught the missing plmbr#301 migration bullet, missing version- matrix 5.0.x row, missing README TOC entry, and a couple of style nits (sub-heading overpromise, orphan bullet). Skipped (deferred to future PRs): - README first-run tour mention. - Admin guide HTTP kill-switch row in Failure-modes table. - Terminal drag-drop trust-model precision update after plmbr#327. - Cipher description nit in plmbr#293 (Fernet AES-128-CBC+HMAC, not AES-GCM).
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
Follow-up to #290. That PR closed the
working_directorytraversal on the two built-in shell tools (run_command_in_embedded_terminalandbuilt_in_toolsets.run_command_in_jupyter_terminal) but left the two Claude-mode siblings open with the same shape of bug. This PR closes them.run-command-in-jupyter-terminalatclaude.py:1071forwardedworking_directorystraight to the JupyterLab UI commandnotebook-intelligence:run-command-in-terminal, which passes it toterminal:create-newascwd. An LLM tool call withworking_directory='/etc','../../..', or a workspace symlink pointing outside opened a terminal there and ran the command.open-file-in-jupyter-uiatclaude.py:1089forwardedfile_pathtodocmanager:open. JupyterLab's contents service today rejects out-of-root absolute paths, but relying on framework behavior left the same shape of bug latent.Solution
Three layers, one commit each, plus a remediation commit for the six-agent review findings.
_get_safe_pathfrombuilt_in_toolsets.pytoutil.safe_jupyter_path(public, next toget_jupyter_root_dir).util.pyhas no NBI imports, so it's a clean chokepoint with no risk of import cycles. The original module keeps a backwards-compat alias so the 8 existing call sites read identically.run_command_in_jupyter_terminalvalidatesworking_directoryand forwards the sandboxed absolute path to the UI bridge (terminal:create-new honors absolute cwd via PTY spawn).open_file_in_jupyter_uivalidatesfile_pathand forwards the relative-to-root form todocmanager:open, since JupyterLab's contents service strips leading slashes and rejoins under root, so an absolute path would 404.safe_jupyter_pathraisesRuntimeError(notValueError) for the "workspace root not set" case so callers'except ValueErrorblocks can't swallow a server-side misconfig as if the LLM had picked a bad path.tool_text_responsegains anis_errorkeyword that maps to MCP'sisErrorflag. Every rejection path now sets it so the model treats sandbox violations as faults to recover from rather than as authoritative output.Testing
tests/test_claude_tool_path_sandbox.pyships 21 cases:TestRunCommandInJupyterTerminalSandbox(10): absolute escape, relative traversal, workspace symlink pointing outside, traversal via a valid-subdir prefix (valid/../../.., pins thatresolve()collapses..before the containment check), embedded NUL, nonexistent dir, file-not-dir, plus positive cases for relative subdir,., and""meaning the root. Every rejection asserts the UI bridge spy was never invoked.TestOpenFileInJupyterUiSandbox(7): absolute escape, relative traversal, workspace symlink, embedded NUL, plus three positive cases including a deeper nested path and a not-yet-existing file (docmanager:open is used for paths the user is about to create, so the gate deliberately doesn't require existence).TestMCPErrorSignalling(4): pinsis_error: Trueon rejection paths and its absence on happy-path returns.TestRootNotSet(1): pins that the unset-root case surfaces as a distinct error message ("not set") rather than getting reflected as a sandbox violation.Mirrors the structure of
tests/test_builtin_toolset_cwd_sandbox.pyfrom PR #290 so the two test files read side-by-side.Verified the new tests fail without the fix by stashing
claude.pyand re-running the absolute-escape cases; both flunk, then pass once the fix is reapplied.Full suite:
pytest tests/ --ignore=tests/test_claude_client.py→ 1029 passed.jlpm tsc --noEmit,jlpm lint:check,jlpm jestall clean.Six-agent review
Six reviewers (code reuse, code quality, security, frontend bridge, test architecture, API contract) ran in parallel against the first three commits. The remediation commit lands every convergent fix-before-merge finding:
docmanager:openregression (security, code quality, frontend bridge): the first revision forwardedstr(target), an absolute path, which JupyterLab's contents service would 404 every time. Fixed by forwardingtarget.relative_to(root_dir).as_posix().is_erroron MCP rejections. Fixed.monkeypatch.setattr.ValueErrorfor the unset-root case. Reclassified asRuntimeError.Documented follow-ups (none security-relevant, deferred to keep this PR scoped): rename the 8
_get_safe_pathcall sites to drop the alias; alignexecute_command's rejection wording with the two terminal tools; relativizejupyter_root_dirout of error text for hosted-deployment safety; document Windows hardlink semantics; add URI-encoded-traversal / whitespace-path / very-long-path test cases.Risks
working_directoryorfile_pathoutside the workspace now gets ais_error: Trueresponse with the documented "outside allowed directory" wording. Matches the existing built-in tools' UX so the model's existing self-correction patterns apply.open-file-in-jupyter-uipath shape: the forwarded payload changes from "whatever the LLM sent" to "the relative-to-root POSIX form." For a relative path the LLM already supplied, this is a normalize-and-pass-through (same path). For an absolute path inside the workspace, the result is a working open instead of a 404. No regression on legitimate calls.