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
2 changes: 1 addition & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ For air-gapped or egress-restricted environments, see [`docs/admin-guide.md`](do

> Treat `~/.jupyter/nbi/config.json` and `~/.jupyter/nbi/user-data.json` as secrets. They contain your API keys and (encrypted) GitHub token. Do not commit them to git, share them, or sync them across users. If a key leaks, rotate it at the provider immediately.

The encrypted GitHub token uses a default password (`nbi-access-token-password`) unless you set `NBI_GH_ACCESS_TOKEN_PASSWORD`. The default is **shared across installs** and provides obfuscation, not real protection. Set a custom password before enabling "remember login" on any shared or multi-tenant system.
The encrypted GitHub token uses a default password (`nbi-access-token-password`) unless you set `NBI_GH_ACCESS_TOKEN_PASSWORD`. The default is **shared across installs** and provides obfuscation, not real protection. Set a custom password before enabling "remember login" on any shared or multi-tenant system. NBI logs a per-process WARNING when the default is in use and escalates the message when `~/.jupyter/nbi/` is group/other-accessible. Operators on shared filesystems can set `NBI_REFUSE_DEFAULT_TOKEN_PASSWORD_ON_SHARED_FS=1` to refuse the write entirely until a per-user password is configured, with `NBI_ALLOW_DEFAULT_TOKEN_PASSWORD=1` available as an explicit per-pod opt-out during a rollout.

## Telemetry

Expand Down
4 changes: 3 additions & 1 deletion docs/admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ For Kubeflow or KubeSpawner: mount the user's home directory on a PVC and ensure
If users share a home directory across nodes (NFS-backed shared HPC, classroom labs):

- **Race conditions in `~/.jupyter/nbi/`.** Concurrent writes from two login nodes can corrupt `config.json`. NBI does not file-lock. Pin each user to one node, or use a per-node config prefix.
- **`NBI_GH_ACCESS_TOKEN_PASSWORD` default is unsafe.** The default password (`nbi-access-token-password`) is shared across installs. On a multi-tenant cluster, anyone with read access to another user's `~/.jupyter/nbi/user-data.json` can decrypt their Copilot token. Set a per-user password (e.g., derived from the Hub user secret), or disable "remember login" entirely (see [Restricting features](#restricting-features-for-managed-deployments)).
- **`NBI_GH_ACCESS_TOKEN_PASSWORD` default is unsafe on shared hosts.** The default password (`nbi-access-token-password`) is shared across installs. On a multi-tenant cluster, anyone with read access to another user's `~/.jupyter/nbi/user-data.json` can decrypt their Copilot token. NBI now logs a per-process WARNING on the first read or write of the stored token when the default password is in use, escalated when `~/.jupyter/nbi/` is readable by group or other. Set `NBI_REFUSE_DEFAULT_TOKEN_PASSWORD_ON_SHARED_FS=1` to upgrade the warning to a hard refusal of the write; admins who knowingly accept the risk can opt out per pod with `NBI_ALLOW_DEFAULT_TOKEN_PASSWORD=1`. The hardening is opt-in to preserve backwards compatibility for single-user deployments where the directory mode is incidental. Set a per-user password (e.g., derived from the Hub user secret), or disable "remember login" entirely (see [Restricting features](#restricting-features-for-managed-deployments)).
- **Skill collisions.** Two users sharing `~/.claude/skills/` will see each other's skills. Make sure each user has a unique home.

---
Expand Down Expand Up @@ -108,6 +108,8 @@ The full surface, in one table.
| `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.** |
| `NBI_REFUSE_DEFAULT_TOKEN_PASSWORD_ON_SHARED_FS` | bool | unset | env | When set, refuse to write `user-data.json` if the default `NBI_GH_ACCESS_TOKEN_PASSWORD` is still in use AND `~/.jupyter/nbi/` is readable by group or other. Opt-in to preserve backwards compatibility on single-user deployments where the directory mode is incidental. |
| `NBI_ALLOW_DEFAULT_TOKEN_PASSWORD` | bool | unset | env | Per-pod opt-out that disengages the refuse-on-shared-fs guard above. Admins who knowingly accept the risk (e.g., during a transition before rolling out a per-user password) set this so writes continue. |
| `NBI_RULES_AUTO_RELOAD` | bool | `true` | env | When `false`, ruleset edits require a JupyterLab restart to take effect. |
| `NBI_CLAUDE_CLI_PATH` | str | unset | env | Absolute path to the Claude Code CLI binary. When unset, NBI looks up `claude` on `PATH`. |
| `NBI_OPENCODE_CLI_PATH` | str | unset | env | Absolute path to the opencode CLI. When unset, NBI looks up `opencode` on `PATH`. Gates the opencode launcher tile. |
Expand Down
105 changes: 103 additions & 2 deletions notebook_intelligence/github_copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import base64
from dataclasses import dataclass
from enum import Enum
import os, json, time, requests, threading
import os, json, stat, time, requests, threading
from typing import Any
import uuid
import secrets
Expand Down Expand Up @@ -104,9 +104,98 @@ def get_login_status():

deprecated_user_data_file = os.path.join(os.path.expanduser('~'), ".jupyter", "nbi-data.json")
user_data_file = os.path.join(os.path.expanduser('~'), ".jupyter", "nbi", "user-data.json")
access_token_password = os.getenv("NBI_GH_ACCESS_TOKEN_PASSWORD", "nbi-access-token-password")
DEFAULT_ACCESS_TOKEN_PASSWORD = "nbi-access-token-password"
access_token_password = os.getenv("NBI_GH_ACCESS_TOKEN_PASSWORD", DEFAULT_ACCESS_TOKEN_PASSWORD)


def _is_default_token_password() -> bool:
return access_token_password == DEFAULT_ACCESS_TOKEN_PASSWORD


# Shared vocab matches _resolve_bool_with_env in extension.py so admins
# don't have to remember a different set of "yes" tokens for this env.
_BOOL_TRUE_VALUES = frozenset({"true", "1", "yes", "on"})


def _env_truthy(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in _BOOL_TRUE_VALUES


def _user_data_dir_is_shared() -> bool:
"""Return True when the directory that holds user-data.json is
readable or writable by group or other.

POSIX-only; on Windows the helper returns False (ACL semantics
don't map onto S_IRGRP/S_IROTH and the deployment threat model is
different). A True return means at least one read or write bit
beyond owner is set, so another tenant on a shared filesystem
could read the file once written, or plant a token by replacing
the file via a writable parent dir.
"""
if os.name != "posix":
return False
target_dir = os.path.dirname(user_data_file)
try:
st = os.stat(target_dir)
except OSError:
return False
risky_bits = stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH
return bool(st.st_mode & risky_bits)


_default_password_warned = False


def _warn_default_password_once() -> None:
"""Emit the default-password WARNING at most once per process.

The audit recommends a startup warning on every server boot until
the password is overridden, but a hot endpoint that calls the
read/write helpers on every request would spam the log. Once per
process is the right cadence; operators see it on boot and on
log rotation.
"""
global _default_password_warned
if _default_password_warned or not _is_default_token_password():
return
_default_password_warned = True
if _user_data_dir_is_shared():
log.warning(
"Storing the GitHub Copilot token under the default "
"NBI_GH_ACCESS_TOKEN_PASSWORD on a directory that is "
"readable by group or other (%s). Set a per-user "
"NBI_GH_ACCESS_TOKEN_PASSWORD, or set "
"NBI_ALLOW_DEFAULT_TOKEN_PASSWORD=1 to acknowledge the "
"risk explicitly.",
os.path.dirname(user_data_file),
)
else:
log.warning(
"Storing the GitHub Copilot token under the default "
"NBI_GH_ACCESS_TOKEN_PASSWORD. Set a per-user password "
"in multi-tenant deployments."
)


def _refuse_write_on_shared_default() -> bool:
"""Return True when the write should be refused.

Refusal is opt-in (``NBI_REFUSE_DEFAULT_TOKEN_PASSWORD_ON_SHARED_FS=1``)
because flipping the default to refuse would break existing
single-user deployments where the directory's mode is incidental.
Once a deployment opts in, the refusal triggers only when both the
password is the documented default AND the target directory is
group/other accessible; an admin who knowingly relaxed perms can
still opt out with ``NBI_ALLOW_DEFAULT_TOKEN_PASSWORD=1``.
"""
if _env_truthy("NBI_ALLOW_DEFAULT_TOKEN_PASSWORD"):
return False
if not _env_truthy("NBI_REFUSE_DEFAULT_TOKEN_PASSWORD_ON_SHARED_FS"):
return False
return _is_default_token_password() and _user_data_dir_is_shared()

def read_stored_github_access_token() -> str:
_warn_default_password_once()
try:
if os.path.exists(user_data_file):
with open(user_data_file, 'r') as file:
Expand Down Expand Up @@ -141,6 +230,18 @@ def _save_user_data(user_data: dict) -> None:


def write_github_access_token(access_token: str) -> bool:
_warn_default_password_once()
if _refuse_write_on_shared_default():
log.error(
"Refusing to write %s: the default NBI_GH_ACCESS_TOKEN_PASSWORD "
"is in use on a directory that is readable by group or other, "
"and NBI_REFUSE_DEFAULT_TOKEN_PASSWORD_ON_SHARED_FS is set. "
"Set NBI_GH_ACCESS_TOKEN_PASSWORD to a per-user secret, "
"tighten the directory mode, or set "
"NBI_ALLOW_DEFAULT_TOKEN_PASSWORD=1 to opt out.",
user_data_file,
)
return False
try:
encrypted_access_token = encrypt_with_password(access_token_password, access_token.encode())
base64_bytes = base64.b64encode(encrypted_access_token)
Expand Down
Loading
Loading