Skip to content

security(claude): sandbox UI-bridge tool paths to jupyter_root#323

Merged
mbektas merged 4 commits into
plmbr:mainfrom
pjdoland:security/sandbox-claude-tool-paths
May 19, 2026
Merged

security(claude): sandbox UI-bridge tool paths to jupyter_root#323
mbektas merged 4 commits into
plmbr:mainfrom
pjdoland:security/sandbox-claude-tool-paths

Conversation

@pjdoland
Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #290. That PR closed the working_directory traversal on the two built-in shell tools (run_command_in_embedded_terminal and built_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-terminal at claude.py:1071 forwarded 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 opened a terminal there and ran the command.
  • open-file-in-jupyter-ui at claude.py:1089 forwarded file_path to docmanager: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.

  • Lifted _get_safe_path from built_in_toolsets.py to util.safe_jupyter_path (public, next to get_jupyter_root_dir). util.py has 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.
  • Applied the gate at both Claude-mode tools. run_command_in_jupyter_terminal validates working_directory and forwards the sandboxed absolute path to the UI bridge (terminal:create-new honors absolute cwd via PTY spawn). open_file_in_jupyter_ui validates file_path and forwards the relative-to-root form to docmanager:open, since JupyterLab's contents service strips leading slashes and rejoins under root, so an absolute path would 404.
  • safe_jupyter_path raises RuntimeError (not ValueError) for the "workspace root not set" case so callers' except ValueError blocks can't swallow a server-side misconfig as if the LLM had picked a bad path.
  • tool_text_response gains an is_error keyword that maps to MCP's isError flag. 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.py ships 21 cases:

  • TestRunCommandInJupyterTerminalSandbox (10): 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, 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): pins is_error: True on 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.py from PR #290 so the two test files read side-by-side.

Verified the new tests fail without the fix by stashing claude.py and 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 jest all 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:

  • Three reviewers converged on the docmanager:open regression (security, code quality, frontend bridge): the first revision forwarded str(target), an absolute path, which JupyterLab's contents service would 404 every time. Fixed by forwarding target.relative_to(root_dir).as_posix().
  • Two reviewers (code quality, API contract) flagged missing is_error on MCP rejections. Fixed.
  • Test architecture flagged a fixture leak (yield/teardown can leave state across tests). Migrated to monkeypatch.setattr.
  • Code quality flagged ValueError for the unset-root case. Reclassified as RuntimeError.

Documented follow-ups (none security-relevant, deferred to keep this PR scoped): rename the 8 _get_safe_path call sites to drop the alias; align execute_command's rejection wording with the two terminal tools; relativize jupyter_root_dir out of error text for hosted-deployment safety; document Windows hardlink semantics; add URI-encoded-traversal / whitespace-path / very-long-path test cases.

Risks

  • Behavior change: an LLM call with an absolute working_directory or file_path outside the workspace now gets a is_error: True response 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-ui path 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.

pjdoland added 4 commits May 19, 2026 08:14
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).
@pjdoland pjdoland added the bug Something isn't working label May 19, 2026
@pjdoland pjdoland added this to the 5.0.x milestone May 19, 2026
@mbektas mbektas merged commit a8543fb into plmbr:main May 19, 2026
4 of 5 checks passed
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants