Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions docs/admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ The full surface, in one table.
| `NBI_UPLOAD_MAX_MB` | int | unset | env (overrides traitlet) | Same as above; env takes precedence. |
| `upload_retention_hours` | Int | `24` | traitlet | How long staged uploads survive in the temp directory before the next upload sweeps them. `0` keeps only the atexit purge (uploads survive the session). |
| `NBI_UPLOAD_RETENTION_HOURS` | int | unset | env (overrides traitlet) | Same as above; env takes precedence. |
| `mcp_stdio_command_allowlist` | List | `[]` | traitlet | Regex allowlist for the stdio MCP server `command` field. Empty list (default) means no enforcement; non-empty list rejects any stdio MCP server whose command does not match. Matched with `re.search`; anchor (`^...$`) for literal equality. Applies at both Claude `mcp add` and `mcp.json` load. See [Restricting MCP stdio commands](#restricting-mcp-stdio-commands). |
| `NBI_MCP_STDIO_COMMAND_ALLOWLIST` | csv | unset | env (appends to traitlet) | Comma-separated regex patterns added to the traitlet at startup. Per-pod additions on an org baseline. |
| `tour_config_path` | str | `""` | traitlet | Filesystem path to a YAML/JSON file with admin overrides for the first-run sidebar tour copy. See [`docs/admin-tour-config.md`](admin-tour-config.md). |
| `NBI_TOUR_CONFIG_PATH` | str | unset | env (overrides traitlet) | Same as above; env takes precedence. |
| `NBI_GH_ACCESS_TOKEN_PASSWORD` | str | `nbi-access-token-password` | env | Password used to encrypt the stored Copilot token in `user-data.json`. **Change in multi-tenant deployments.** |
Expand Down Expand Up @@ -140,7 +142,7 @@ NBI runs entirely inside the user's Jupyter Server process. There is no privileg
- `nbi-command-execute` runs arbitrary shell commands.
- `nbi-file-edit` and `nbi-file-read` read and write any file the user can.
- `nbi-notebook-edit` and `nbi-notebook-execute` modify and run notebooks.
- **MCP stdio servers** are launched as user subprocesses with the user's environment. NBI does not sandbox them.
- **MCP stdio servers** are launched as user subprocesses with the user's environment. NBI does not sandbox them; the optional [`mcp_stdio_command_allowlist`](#restricting-mcp-stdio-commands) gates the binary name and refuses `PATH`/`LD_PRELOAD` style env overrides, but does not validate `args` and does not contain the spawned process.
- **Claude Code CLI** inherits the user's environment, including filesystem permissions and any auth tokens in `~/.claude/`.

For regulated tenants:
Expand Down Expand Up @@ -474,7 +476,33 @@ Reads come from Claude's JSON config files directly (fast, no health checks). Wr

> **Blast radius.** Force-off only kills the _management UI_ — MCP servers already configured in `~/.claude.json` or `<cwd>/.mcp.json` keep loading inside Claude Code sessions because Claude's MCP loader doesn't consult NBI's policy. To stop existing servers, remove them on disk (or via the `claude mcp remove` CLI) before flipping the policy.

> **Trust model.** MCP servers run as subprocesses (stdio transport) or accept arbitrary URLs (sse/http transport) inside Claude Code sessions; NBI does not validate or sandbox the command, environment, or network endpoint beyond rejecting CLI flag-smuggling. For multi-tenant or regulated deployments, default to `claude_mcp_management_policy = force-off` and ship a curated set of servers via `~/.claude/settings.json` instead.
> **Trust model.** MCP servers run as subprocesses (stdio transport) or accept arbitrary URLs (sse/http transport) inside Claude Code sessions. Beyond CLI flag-smuggling rejection and HTTPS-required URL transports, NBI validates only the binary name against the optional [`mcp_stdio_command_allowlist`](#restricting-mcp-stdio-commands) and refuses a handful of env-key bypasses (`PATH`, `LD_PRELOAD`, `PYTHONPATH`, `NODE_OPTIONS`, etc.) when the gate is engaged. `args` and the spawned process are not contained. For multi-tenant or regulated deployments, prefer `claude_mcp_management_policy = force-off` plus a curated set of servers via `~/.claude/settings.json`.

### Restricting MCP stdio commands

When `mcp_stdio_command_allowlist` is non-empty, every stdio MCP server (whether added via the Claude-mode UI or loaded from `~/.jupyter/nbi/mcp.json`) must match at least one pattern in the list. Empty list (the default) means no enforcement.

```python
c.NotebookIntelligence.mcp_stdio_command_allowlist = [
"^/usr/local/bin/uv$",
"^/usr/local/bin/uvx$",
"^/usr/local/bin/npx$",
]
```

Or via env (appends to the traitlet, useful for per-pod adds on an org baseline):

```
NBI_MCP_STDIO_COMMAND_ALLOWLIST=^/usr/local/bin/uv$,^/usr/local/bin/uvx$
```

Patterns use `re.search`, so anchor with `^...$` for literal equality. `"uv"` matches both `uv` and `uvtool`; `"^uv$"` matches only `uv`.

Whenever the gate engages on a stdio server, NBI additionally refuses dangerous env keys (`PATH`, `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_AUDIT`, `DYLD_*`, `PYTHONPATH`, `PYTHONSTARTUP`, `PYTHONHOME`, `NODE_OPTIONS`, `NODE_PATH`, `BASH_ENV`, `ENV`) regardless of whether the allowlist is set. This closes the bypass where a poisoned `PATH` resolves an allowlisted binary name to attacker-controlled code, or where a `LD_PRELOAD` injects a shared object into the process before its entry point.

**Scope.** The gate matches the `command` field only. `args` flow through unchecked, so an allowlist that permits `npx` will still accept `args: ['-y', 'evil-pkg']`. If you need argv-level control, point `command` at a wrapper script you own that bakes the safe argv in.

**Behavior on rejection.** Claude-mode `mcp add` returns HTTP 400 with the policy error message so the user sees it in the Settings UI. The `mcp.json` loader logs a warning naming the server and skips it; the rest of the MCP list keeps loading.

### Disabling the Plugins tab

Expand Down
14 changes: 13 additions & 1 deletion notebook_intelligence/ai_service_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ def get_skill_reconciler(self) -> Optional[SkillReconciler]:
"""Get the managed-skills reconciler, if one is configured."""
return self._skill_reconciler

def get_mcp_stdio_command_allowlist(self) -> list[str]:
"""Return the merged MCP stdio-command regex allowlist (traitlet + env).

Empty list means no enforcement. Handlers that construct a
``ClaudeMCPManager`` per request read this so the gate stays
consistent with the in-process ``MCPManager``.
"""
return list(self._options.get("mcp_stdio_command_allowlist") or [])

@property
def websocket_connector(self) -> ThreadSafeWebSocketConnector:
return self._websocket_connector
Expand All @@ -131,7 +140,10 @@ def initialize(self):
self.register_llm_provider(self._openai_compatible_llm_provider)
self.register_llm_provider(self._litellm_compatible_llm_provider)
self.register_llm_provider(self._ollama_llm_provider)
self._mcp_manager = MCPManager(self.nbi_config.mcp)
self._mcp_manager = MCPManager(
self.nbi_config.mcp,
stdio_command_allowlist=self._options.get("mcp_stdio_command_allowlist") or [],
)
for participant in self._mcp_manager.get_mcp_participants():
# A duplicate / reserved id from one MCP server should not block
# the rest from registering — log and continue rather than crash
Expand Down
25 changes: 24 additions & 1 deletion notebook_intelligence/claude_mcp_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
validate_scope,
)
from notebook_intelligence.config import _atomic_write_json
from notebook_intelligence.mcp_policy import (
reject_dangerous_env_keys,
validate_mcp_stdio_command,
)

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -131,9 +135,18 @@ class ClaudeMCPManager:
# two in-flight `add`s from the same NBI server can clobber.
_write_lock = asyncio.Lock()

def __init__(self, working_dir: Optional[str] = None):
def __init__(
self,
working_dir: Optional[str] = None,
*,
stdio_command_allowlist: Optional[Iterable[str]] = None,
):
self._working_dir = Path(working_dir) if working_dir else Path.cwd()
self._user_config_path = Path.home() / ".claude.json"
# An empty allowlist means "no enforcement"; the default keeps
# per-user deployments permissive. Admins thread their pinned
# regex list in via ``stdio_command_allowlist``.
self._stdio_command_allowlist: list[str] = list(stdio_command_allowlist or [])

# --- reads ---------------------------------------------------------

Expand Down Expand Up @@ -201,6 +214,16 @@ async def add_server(
raise ValueError("Missing command or URL")
reject_flag_smuggling("name", name)
reject_flag_smuggling("command_or_url", command_or_url)
# Admin-configured allowlist gates the stdio binary against an
# opt-in regex list. Default is empty (no enforcement) so existing
# single-user deployments stay permissive. URL transports skip the
# gate because they don't fork a local process. The env-key check
# runs unconditionally because PATH / LD_PRELOAD style bypasses
# would otherwise convert a binary-name allowlist into a false
# sense of security regardless of whether the allowlist is set.
if transport == "stdio":
validate_mcp_stdio_command(command_or_url, self._stdio_command_allowlist)
reject_dangerous_env_keys(env)
# For network transports, require HTTPS — `http://`/`file://`/etc.
# would let a user point Claude at internal endpoints (SSRF) or
# exfil credentials in plaintext. Stdio is a subprocess, not a URL.
Expand Down
75 changes: 74 additions & 1 deletion notebook_intelligence/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@
MCPConfigValidationError,
validate_mcp_config,
)
from notebook_intelligence.mcp_policy import (
reject_dangerous_env_keys,
validate_mcp_stdio_command,
)
from notebook_intelligence.claude import ClaudeCodeChatParticipant, fetch_claude_models
from notebook_intelligence.claude_mcp_manager import ClaudeMCPManager
from notebook_intelligence.plugin_manager import PluginManager
Expand Down Expand Up @@ -725,11 +729,32 @@ def post(self):
self.finish(json.dumps({"status": "error", "message": str(exc)}))
return
try:
# Validate stdio entries against the same admin allowlist
# that the in-process loader uses, so a rejected entry
# cannot persist to disk and re-trigger the load-time warn
# on every restart. Apply the same env-key denylist that
# blocks PATH / LD_PRELOAD / etc. bypasses.
allowlist = ai_service_manager.get_mcp_stdio_command_allowlist()
servers = data.get("mcpServers") if isinstance(data, dict) else None
if isinstance(servers, dict):
for name, server in servers.items():
if not isinstance(server, dict) or "command" not in server:
continue
validate_mcp_stdio_command(server.get("command", ""), allowlist)
reject_dangerous_env_keys(server.get("env"))
ai_service_manager.nbi_config.user_mcp = data
ai_service_manager.nbi_config.save()
ai_service_manager.nbi_config.load()
ai_service_manager.update_mcp_servers()
self.finish(json.dumps({"status": "ok"}))
except ValueError as exc:
# Policy rejection: surface as HTTP 400 so the Settings UI
# shows the operator's policy message instead of a generic
# 500. The body still uses the {status, message} envelope
# the frontend already parses.
self.set_status(400)
self.finish(json.dumps({"status": "error", "message": str(exc)}))
return
except Exception as e:
self.set_status(500)
self.finish(json.dumps({"status": "error", "message": str(e)}))
Expand Down Expand Up @@ -944,10 +969,16 @@ class ClaudeMCPBaseHandler(PolicyGatedHandler):
FileNotFoundError: 404,
TimeoutError: 504,
}
# Set once at startup from the merged (traitlet + env) admin allowlist.
# Empty list means no enforcement; consult ``AIServiceManager``.
mcp_stdio_command_allowlist: list = []

@property
def manager(self) -> "ClaudeMCPManager":
return ClaudeMCPManager(working_dir=get_jupyter_root_dir() or None)
return ClaudeMCPManager(
working_dir=get_jupyter_root_dir() or None,
stdio_command_allowlist=self.mcp_stdio_command_allowlist,
)


class ClaudeMCPListHandler(ClaudeMCPBaseHandler):
Expand Down Expand Up @@ -2444,6 +2475,40 @@ class NotebookIntelligence(ExtensionApp):
config=True,
)

mcp_stdio_command_allowlist = List(
trait=Unicode(),
default_value=None,
help="""
Regex allowlist for the stdio MCP server `command` field. When
non-empty, every stdio MCP server (added via Claude `mcp add`
and loaded from `mcp.json`) must match at least one pattern;
otherwise the admin gate rejects the server. Empty list (the
default) means no enforcement.

Patterns are matched with `re.search`. Anchor with `^...$` to
require an exact binary, otherwise `'uv'` matches both `uv` and
`uvtool`. Anchor on an absolute path (`'^/usr/local/bin/uv$'`)
if you want to defeat PATH-poisoning that points at a different
binary with the same basename. The complementary `env` denylist
(PATH, LD_PRELOAD, PYTHONPATH, NODE_OPTIONS, etc.) is always
applied to stdio servers regardless of this setting.

Patterns can be added per pod via the
`NBI_MCP_STDIO_COMMAND_ALLOWLIST` environment variable (CSV;
appends to this list).

Scope: this gate validates the binary `command` only. `args`
flow through unchecked, so an allowlist that permits `npx` will
still accept `args: ['-y', 'evil-pkg']`. Admins who need
argv-level control should point `command` at a wrapper script
they own that bakes the safe argv in.

Example: ['^uv$', '^uvx$', '^npx$', '^/usr/local/bin/.*']
""",
allow_none=True,
config=True,
)

allow_enabling_coding_agent_launchers_with_env = Bool(
default_value=False,
help="""
Expand Down Expand Up @@ -2842,13 +2907,18 @@ def initialize_ai_service(
log.warning(
"Ignoring invalid NBI_SKILLS_MANIFEST_INTERVAL=%r", interval_env
)
mcp_command_allowlist = _resolve_csv_appended(
"NBI_MCP_STDIO_COMMAND_ALLOWLIST",
self.mcp_stdio_command_allowlist,
)
ai_service_manager = AIServiceManager({
"server_root_dir": server_root_dir,
"skills_manifest_sources": manifest_sources,
"skills_manifest_interval": manifest_interval,
"managed_skills_token": managed_token,
"feature_policies": feature_policies,
"string_overrides": string_overrides,
"mcp_stdio_command_allowlist": mcp_command_allowlist,
})

def initialize_templates(self):
Expand Down Expand Up @@ -2971,6 +3041,9 @@ def _setup_handlers(self, web_app, feature_policies: dict, string_overrides: dic
ClaudeMCPBaseHandler.claude_mcp_management_enabled = not is_force_off(
feature_policies, "claude_mcp_management"
)
ClaudeMCPBaseHandler.mcp_stdio_command_allowlist = (
ai_service_manager.get_mcp_stdio_command_allowlist()
)
PluginsBaseHandler.claude_plugins_management_enabled = not is_force_off(
feature_policies, "claude_plugins_management"
)
Expand Down
Loading
Loading