diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 8657179b73..a4fd07c3b6 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -5778,6 +5778,7 @@ class LlamaCppBackend: seed: Optional[int] = None, disable_parallel_tool_use: bool = False, confirm_tool_calls: bool = False, + bypass_permissions: bool = False, ) -> Generator[dict, None, None]: """ Agentic loop: let the model call tools, execute them, and continue. @@ -6478,7 +6479,9 @@ class LlamaCppBackend: decision.as_assistant_tool_call() ) - needs_confirm = bool(confirm_tool_calls) + # Bypass wins over the confirm gate at the loop level too, + # so a direct internal caller with both flags never prompts. + needs_confirm = bool(confirm_tool_calls) and not bypass_permissions approval_id = new_approval_id() if needs_confirm else "" decision_slot = ( begin_tool_decision(session_id, approval_id) if needs_confirm else None @@ -6539,6 +6542,7 @@ class LlamaCppBackend: timeout = _effective_timeout, session_id = session_id, rag_scope = rag_scope, + disable_sandbox = bypass_permissions, ) if decision.tool_name == "search_knowledge_base": _kb_search_count += 1 diff --git a/studio/backend/core/inference/orchestrator.py b/studio/backend/core/inference/orchestrator.py index 4ccac2912e..4fa48a2d88 100644 --- a/studio/backend/core/inference/orchestrator.py +++ b/studio/backend/core/inference/orchestrator.py @@ -862,6 +862,7 @@ class InferenceOrchestrator: session_id: Optional[str] = None, rag_scope: Optional[dict] = None, confirm_tool_calls: bool = False, + bypass_permissions: bool = False, use_adapter: Optional[Union[bool, str]] = None, stats_holder: Optional[dict] = None, **_unused, @@ -924,6 +925,7 @@ class InferenceOrchestrator: session_id = session_id, rag_scope = rag_scope, confirm_tool_calls = confirm_tool_calls, + bypass_permissions = bypass_permissions, ) def generate_with_adapter_control( diff --git a/studio/backend/core/inference/safetensors_agentic.py b/studio/backend/core/inference/safetensors_agentic.py index 3b6a393f3d..06b6cbe57f 100644 --- a/studio/backend/core/inference/safetensors_agentic.py +++ b/studio/backend/core/inference/safetensors_agentic.py @@ -154,6 +154,7 @@ def run_safetensors_tool_loop( session_id: Optional[str] = None, rag_scope: Optional[dict] = None, confirm_tool_calls: bool = False, + bypass_permissions: bool = False, ) -> Generator[dict, None, None]: """Drive an agentic tool loop on top of a cumulative-text generator. @@ -517,7 +518,9 @@ def run_safetensors_tool_loop( else: assistant_msg.setdefault("tool_calls", []).append(decision.as_assistant_tool_call()) - needs_confirm = bool(confirm_tool_calls) + # Bypass wins over the confirm gate at the loop level too, so a + # direct internal caller passing both flags never prompts. + needs_confirm = bool(confirm_tool_calls) and not bypass_permissions approval_id = new_approval_id() if needs_confirm else "" decision_slot = begin_tool_decision(session_id, approval_id) if needs_confirm else None start_event = decision.tool_start_event() @@ -575,6 +578,7 @@ def run_safetensors_tool_loop( timeout = eff_timeout, session_id = session_id, rag_scope = rag_scope, + disable_sandbox = bypass_permissions, ) except Exception as exc: logger.exception("Tool %s raised: %s", decision.tool_name, exc) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 43c9610282..b29a221ee7 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -316,6 +316,196 @@ def _build_safe_env(workdir: str) -> dict[str, str]: return env +# Credential env vars dropped even in bypass mode so tool code cannot read the +# operator's keys. Over-strips on purpose (a benign var is harmless to lose). +_BYPASS_ENV_SECRET_NAMES = frozenset( + { + "HF_TOKEN", + "HF_HUB_TOKEN", + "HUGGING_FACE_HUB_TOKEN", + "HUGGINGFACE_TOKEN", + "HUGGINGFACEHUB_API_TOKEN", + "WANDB_API_KEY", + "GH_TOKEN", + "GITHUB_TOKEN", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GROQ_API_KEY", + "OPENROUTER_API_KEY", + "REPLICATE_API_TOKEN", + "COHERE_API_KEY", + "MISTRAL_API_KEY", + "NGC_API_KEY", + "KAGGLE_KEY", + "MYSQL_PWD", # exact name: markers use PASSWD, not PWD (PWD is the cwd var) + "LD_PRELOAD", + # Auth brokers / capability handles: not secrets by value, but they + # hand the child the operator's live agent (ssh/gpg), kube config, or + # docker daemon. Names are listed because there is no value signal to + # key off. URL config vars (HTTP_PROXY, PIP_INDEX_URL, DATABASE_URL, + # ...) are intentionally NOT name-listed: a benign proxy/index without + # credentials must keep working in bypass mode, while a credentialed + # value is dropped by _is_secret_env_value() regardless of its name. + "SSH_AUTH_SOCK", + "SSH_AGENT_PID", + "GPG_AGENT_INFO", + "GNUPGHOME", + "KUBECONFIG", + "DOCKER_HOST", + } +) +_BYPASS_ENV_SECRET_PREFIXES = ("AWS_", "AZURE_", "GOOGLE_", "GCP_", "GCLOUD_", "DYLD_") +_BYPASS_ENV_SECRET_MARKERS = ( + "TOKEN", + "API_KEY", + "APIKEY", + "SECRET", + "PASSWORD", + "PASSWD", + "CREDENTIAL", + "PRIVATE_KEY", + "AUTH", # e.g. NPM_CONFIG__AUTH (npm _auth), REDISCLI_AUTH + # Azure App Service connection strings: SQLCONNSTR_/CUSTOMCONNSTR_/... and + # WEBSITE_CONTENTAZUREFILECONNECTIONSTRING carry DB/storage credentials. + "CONNSTR", + "CONNECTIONSTRING", +) +# Non-secret hardening flags that match a secret prefix/marker but must be KEPT +# so bypass mode does not silently undo an operator's opt-out. AWS_EC2_METADATA_ +# DISABLED tells the AWS SDK/CLI not to pull instance-role creds from IMDS; +# dropping it would re-open that path for a bypassed tool. +_BYPASS_ENV_KEEP_NAMES = frozenset( + { + "AWS_EC2_METADATA_DISABLED", + "AWS_EC2_METADATA_V1_DISABLED", + } +) +# Matches a URL that embeds userinfo before the host, covering both +# "scheme://user:pass@host" and token-only "scheme://token@host" (and +# percent-encoded variants). The userinfo must precede the first '/', so an '@' +# in a path or query does not false-positive. Used to scrub credential-bearing +# URL values regardless of the variable's name. +_URL_USERINFO_RE = re.compile(r"://[^/\s@]+@") +# Connection-string credential fields (ADO.NET / Azure storage / Service Bus): +# "...;Password=...", "...;AccountKey=...", "...;SharedAccessKey=...". Catches +# credential-bearing values whose names dodge the name classifier. "accesskey" +# also covers Shared/Secret AccessKey via substring; the Name fields (e.g. +# SharedAccessKeyName=) do not match since "=" must follow the keyword. +_SECRET_VALUE_RE = re.compile(r"(?i)(?:password|pwd|accountkey|accesskey)\s*=\s*[^\s;]") + +# Names that hold no secret value but point SDKs at the operator's real +# home/cache/config (cached tokens, cred files), defeating the HOME repoint. +# Startup always sets HF_HOME (-> $HF_HOME/token), so this is the live leak. +# Dropped in bypass mode so tools fall back to the empty repointed HOME. +_BYPASS_ENV_CRED_LOCATION_NAMES = frozenset( + { + # HF cache roots (token lives under $HF_HOME/token) + "HF_HOME", + "HF_HUB_CACHE", + "HUGGINGFACE_HUB_CACHE", + "HF_XET_CACHE", + "TRANSFORMERS_CACHE", + "HF_DATASETS_CACHE", + "HF_ASSETS_CACHE", + # XDG base dirs (resolved before $HOME) + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + # explicit cred/config file pointers honoured before $HOME + "NETRC", + "PGPASSFILE", + "BOTO_CONFIG", + "PIP_CONFIG_FILE", + "CLOUDSDK_CONFIG", + "KAGGLE_CONFIG_DIR", + "DOCKER_CONFIG", + "WANDB_DIR", + "WANDB_CONFIG_DIR", + "WANDB_CACHE_DIR", + # package-manager / git / cloud config pointers to real cred files + "NPM_CONFIG_USERCONFIG", + "NPM_CONFIG_GLOBALCONFIG", + "YARN_RC_FILENAME", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + "CARGO_HOME", + "RCLONE_CONFIG", + # auth-helper scripts that hand creds to git/ssh + "GIT_ASKPASS", + "SSH_ASKPASS", + # shell startup hook: bash -c sources $BASH_ENV (can re-export secrets) + "BASH_ENV", + # Windows: HOMEDRIVE+HOMEPATH compose a home that bypasses HOME + "HOMEDRIVE", + "HOMEPATH", + } +) +# Windows profile dirs SDKs read creds under; repointed (not dropped) since +# callers expect them present. +_BYPASS_ENV_WINDOWS_PROFILE_VARS = ("USERPROFILE", "APPDATA", "LOCALAPPDATA") + + +def _is_secret_env_name(name: str) -> bool: + """True if an env var name looks like it carries a credential.""" + upper = name.upper() + if upper in _BYPASS_ENV_KEEP_NAMES: + return False # non-secret hardening flag; keep it + if upper in _BYPASS_ENV_SECRET_NAMES: + return True + if any(upper.startswith(p) for p in _BYPASS_ENV_SECRET_PREFIXES): + return True + return any(marker in upper for marker in _BYPASS_ENV_SECRET_MARKERS) + + +def _is_cred_location_env_name(name: str) -> bool: + """True for vars that point SDKs at the real home/cache/config (cached creds).""" + return name.upper() in _BYPASS_ENV_CRED_LOCATION_NAMES + + +def _is_secret_env_value(value: str) -> bool: + """True if a value embeds credentials regardless of its name. + + Catches URL userinfo (``scheme://user:token@host`` in DATABASE_URL / + PIP_INDEX_URL / HTTP_PROXY) and connection-string credential fields + (``...;Password=...`` / ``...;AccountKey=...``) whose names dodge the name + classifier. + """ + if not value: + return False + return _URL_USERINFO_RE.search(value) is not None or _SECRET_VALUE_RE.search(value) is not None + + +def _build_bypass_env(workdir: str) -> dict[str, str]: + """Env for bypass exec: full host env (unrestricted) minus credential vars, + with HOME/TMPDIR repointed at the workdir so SDKs cannot read cached creds. + + Note: stripping the child env is necessary but not sufficient on its own - + a same-UID child can still read the parent's environment via procfs, so + callers also harden the parent (see _harden_parent_against_proc_env_leak). + """ + env = { + k: v + for k, v in os.environ.items() + if not _is_secret_env_name(k) + and not _is_secret_env_value(v) + and not _is_cred_location_env_name(k) + } + env["HOME"] = workdir + env["TMPDIR"] = workdir + # Windows tempfile / SDKs honour TEMP/TMP, not TMPDIR; repoint all three so + # the bypassed tool writes under the per-session sandbox dir on every OS. + env["TEMP"] = workdir + env["TMP"] = workdir + # Windows SDKs read creds under the profile dirs, not $HOME; repoint set + # ones to the workdir (HOMEDRIVE/HOMEPATH are dropped above). + for var in _BYPASS_ENV_WINDOWS_PROFILE_VARS: + if var in os.environ: + env[var] = workdir + return env + + def _sandbox_preexec(): """Best-effort sandbox setup for sandboxed subprocesses (modules are resolved at import time so the forked child runs no imports).""" @@ -377,6 +567,65 @@ def _sandbox_preexec(): pass +def _bypass_preexec(): + """Minimal pre-exec for bypass exec: os.setsid() only. + + Required, not a restriction: _kill_process_tree does killpg(getpgid(child)), + so without a new session a timeout/cancel would kill the Studio server too. + """ + try: + os.setsid() + except OSError: + pass + + +# Hardening the Studio parent is done once (PR_SET_DUMPABLE is process-global +# and sticky); guarded so repeated bypass calls do not re-issue the prctl. +_parent_proc_hardened = False + + +def _harden_parent_against_proc_env_leak() -> bool: + """Make the Studio process's /proc//environ unreadable to its children. + + Stripping the child env is not enough on Linux: a bypassed same-UID child + runs unsandboxed and can read /proc//environ to recover the + tool-executing process's *unfiltered* secrets (HF_TOKEN, cloud keys, ...). + Clearing the dumpable flag (PR_SET_DUMPABLE=0) reparents this process's + /proc entries to root, so a same-UID child can no longer read its environ. + + Returns True when the process is hardened or hardening is unnecessary (no + /proc leak off Linux), and False when it is needed but could not be applied + (e.g. prctl denied by a seccomp policy). Callers must fail closed - refuse + the unsandboxed exec - when this returns False, rather than running with the + parent environ still readable. + + Scope: this closes the direct parent read (the demonstrated leak). It is a + mitigation, not a full boundary - a bypassed tool is unsandboxed by design, + so it can still walk /proc to a same-UID *ancestor* (e.g. the launching + shell) or read on-disk credentials by absolute path. Complete isolation + needs a separate uid / PID+mount namespace, which is out of scope here; the + UI already warns the mode is dangerous. Applied lazily on first bypass exec + so non-bypass operation is unchanged. + """ + global _parent_proc_hardened + if _parent_proc_hardened: + return True + if sys.platform != "linux": + return True # no /proc//environ same-UID leak to close + if _libc is None: + return False # on Linux but cannot issue prctl -> cannot harden + try: + # prctl(PR_SET_DUMPABLE=4, SUID_DUMP_DISABLE=0). ctypes returns the + # syscall result (-1 on failure) and does NOT raise, so check it. + ret = _libc.prctl(4, 0, 0, 0, 0) + except (OSError, AttributeError): + return False + if ret != 0: + return False + _parent_proc_hardened = True + return True + + def _get_shell_cmd(command: str) -> list[str]: """Return the platform-appropriate shell invocation for a command string.""" if sys.platform == "win32": @@ -723,6 +972,7 @@ def execute_tool( timeout: int | None = _TIMEOUT_UNSET, session_id: str | None = None, rag_scope: dict | None = None, + disable_sandbox: bool = False, ) -> str: """Execute a tool by name with the given arguments; returns a string. @@ -730,6 +980,9 @@ def execute_tool( ``session_id``: optional ID for per-conversation sandbox isolation. ``rag_scope``: hidden per-request RAG context the model never sees; consumed by ``search_knowledge_base``. + ``disable_sandbox``: Bypass Permissions; run python/terminal without the + safety checks, blocklist, or resource caps (secrets still stripped). Only + affects local code tools; web_search / MCP are unchanged. """ logger.info(f"execute_tool: name={name}, session_id={session_id}, timeout={timeout}") effective_timeout = _EXEC_TIMEOUT if timeout is _TIMEOUT_UNSET else timeout @@ -765,9 +1018,21 @@ def execute_tool( timeout = effective_timeout, ) if name == "python": - return _python_exec(arguments.get("code", ""), cancel_event, effective_timeout, session_id) + return _python_exec( + arguments.get("code", ""), + cancel_event, + effective_timeout, + session_id, + disable_sandbox = disable_sandbox, + ) if name == "terminal": - return _bash_exec(arguments.get("command", ""), cancel_event, effective_timeout, session_id) + return _bash_exec( + arguments.get("command", ""), + cancel_event, + effective_timeout, + session_id, + disable_sandbox = disable_sandbox, + ) return f"Unknown tool: {name}" @@ -2242,15 +2507,28 @@ def _python_exec( cancel_event = None, timeout: int = _EXEC_TIMEOUT, session_id: str | None = None, + disable_sandbox: bool = False, ) -> str: - """Execute Python code in a subprocess sandbox.""" + """Execute Python code in a subprocess sandbox. + + disable_sandbox (Bypass Permissions): skip the safety analysis and rlimit + pre-exec, and use the host env minus secrets. + """ if not code or not code.strip(): return "No code provided." - # Validate imports and code safety - error = _check_code_safety(code) - if error: - return error + # Validate imports and code safety (skipped when the sandbox is disabled) + if not disable_sandbox: + error = _check_code_safety(code) + if error: + return error + elif not _harden_parent_against_proc_env_leak(): + # Close the /proc//environ secret-recovery path first; if it + # cannot be applied, fail closed rather than leak the parent environ. + return ( + "Execution error: could not harden the Studio process against " + "/proc environment reads; refusing bypass execution." + ) tmp_path = None workdir = _get_workdir(session_id) @@ -2270,7 +2548,7 @@ def _python_exec( with os.fdopen(fd, "w") as f: f.write(code) - safe_env = _build_safe_env(workdir) + safe_env = _build_bypass_env(workdir) if disable_sandbox else _build_safe_env(workdir) popen_kwargs = dict( stdout = subprocess.PIPE, stderr = subprocess.STDOUT, @@ -2279,7 +2557,7 @@ def _python_exec( env = safe_env, ) if sys.platform != "win32": - popen_kwargs["preexec_fn"] = _sandbox_preexec + popen_kwargs["preexec_fn"] = _bypass_preexec if disable_sandbox else _sandbox_preexec else: popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW @@ -2346,19 +2624,32 @@ def _bash_exec( cancel_event = None, timeout: int = _EXEC_TIMEOUT, session_id: str | None = None, + disable_sandbox: bool = False, ) -> str: - """Execute a bash command in a subprocess sandbox.""" + """Execute a bash command in a subprocess sandbox. + + disable_sandbox (Bypass Permissions): skip the command blocklist and rlimit + pre-exec, and use the host env minus secrets. + """ if not command or not command.strip(): return "No command provided." - # Block dangerous commands (shlex + regex based) - blocked = _find_blocked_commands(command) - if blocked: - return f"Blocked command(s) for safety: {', '.join(sorted(blocked))}" + # Block dangerous commands (skipped when the sandbox is disabled) + if not disable_sandbox: + blocked = _find_blocked_commands(command) + if blocked: + return f"Blocked command(s) for safety: {', '.join(sorted(blocked))}" + elif not _harden_parent_against_proc_env_leak(): + # Close the /proc//environ secret-recovery path first; if it + # cannot be applied, fail closed rather than leak the parent environ. + return ( + "Execution error: could not harden the Studio process against " + "/proc environment reads; refusing bypass execution." + ) try: workdir = _get_workdir(session_id) - safe_env = _build_safe_env(workdir) + safe_env = _build_bypass_env(workdir) if disable_sandbox else _build_safe_env(workdir) popen_kwargs = dict( stdout = subprocess.PIPE, stderr = subprocess.STDOUT, @@ -2367,7 +2658,7 @@ def _bash_exec( env = safe_env, ) if sys.platform != "win32": - popen_kwargs["preexec_fn"] = _sandbox_preexec + popen_kwargs["preexec_fn"] = _bypass_preexec if disable_sandbox else _sandbox_preexec else: popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 5b68364687..82949870da 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -732,6 +732,10 @@ class ChatCompletionRequest(BaseModel): None, description = "[x-unsloth] When true, pause before each tool call and wait for the user to allow/deny it via POST /api/inference/tool-confirm.", ) + bypass_permissions: Optional[bool] = Field( + False, + description = "[x-unsloth] Bypass Permissions: when true, skip the tool-call confirmation gate AND disable the python/terminal execution sandbox (safety checks, command blocklist, resource limits). Secret env vars are still stripped. Takes precedence over confirm_tool_calls.", + ) auto_heal_tool_calls: Optional[bool] = Field( True, description = "[x-unsloth] Auto-detect and fix malformed tool calls from model output.", @@ -1560,6 +1564,10 @@ class AnthropicMessagesRequest(BaseModel): enabled_tools: Optional[list[str]] = None session_id: Optional[str] = None cancel_id: Optional[str] = None + bypass_permissions: Optional[bool] = Field( + False, + description = "[x-unsloth] Bypass Permissions: when true, disable the python/terminal execution sandbox (safety checks, command blocklist, resource limits) for server-side tool calls. Secret env vars are still stripped. Declared explicitly (not relied on via extra='allow') so omitted requests default to False instead of raising AttributeError.", + ) model_config = {"extra": "allow"} @model_validator(mode = "before") diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 3e13774639..c272c67944 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -3522,12 +3522,18 @@ async def openai_chat_completions( # ── External provider routing ──────────────────────────────── # encrypted_api_key is optional -- local providers (llama.cpp / vLLM / Ollama) may run without auth. if payload.provider_id or payload.provider_type: - if payload.confirm_tool_calls and ( - payload.enable_tools is True - or bool(payload.enabled_tools) - or bool(payload.tools) - or bool(payload.openai_code_exec_container_id) - or bool(payload.anthropic_code_exec_container_id) + # Bypass Permissions suppresses the confirm gate, so do not reject a + # request that sets both flags (effective confirm is then False). + if ( + payload.confirm_tool_calls + and not payload.bypass_permissions + and ( + payload.enable_tools is True + or bool(payload.enabled_tools) + or bool(payload.tools) + or bool(payload.openai_code_exec_container_id) + or bool(payload.anthropic_code_exec_container_id) + ) ): raise HTTPException( status_code = 400, @@ -3909,7 +3915,9 @@ async def openai_chat_completions( use_tools = False if use_tools: - if payload.confirm_tool_calls and not payload.stream: + # Bypass Permissions suppresses confirm, so the stream requirement + # (the gate needs streaming to prompt) no longer applies. + if payload.confirm_tool_calls and not payload.bypass_permissions and not payload.stream: raise HTTPException( status_code = 400, detail = openai_error_body( @@ -3989,7 +3997,11 @@ async def openai_chat_completions( session_id = payload.session_id, rag_scope = payload.rag_scope, disable_parallel_tool_use = payload.parallel_tool_calls is False, - confirm_tool_calls = bool(payload.confirm_tool_calls), + # Bypass Permissions takes precedence over the confirm gate: + # never prompt while bypassing. + confirm_tool_calls = bool(payload.confirm_tool_calls) + and not bool(payload.bypass_permissions), + bypass_permissions = bool(payload.bypass_permissions), ) _tool_sentinel = object() @@ -4454,7 +4466,9 @@ async def openai_chat_completions( _sf_use_tools = False if _sf_use_tools: - if payload.confirm_tool_calls and not payload.stream: + # Bypass Permissions suppresses confirm, so the stream requirement + # (the gate needs streaming to prompt) no longer applies. + if payload.confirm_tool_calls and not payload.bypass_permissions and not payload.stream: raise HTTPException( status_code = 400, detail = openai_error_body( @@ -4540,7 +4554,11 @@ async def openai_chat_completions( else 300, session_id = payload.session_id, rag_scope = payload.rag_scope, - confirm_tool_calls = bool(payload.confirm_tool_calls), + # Bypass Permissions takes precedence over the confirm gate: + # never prompt while bypassing. + confirm_tool_calls = bool(payload.confirm_tool_calls) + and not bool(payload.bypass_permissions), + bypass_permissions = bool(payload.bypass_permissions), use_adapter = payload.use_adapter, stats_holder = _sf_stats_holder, ) @@ -6927,7 +6945,10 @@ async def anthropic_messages( ) if server_tools: - if bool(getattr(payload, "confirm_tool_calls", False)): + # Bypass Permissions suppresses confirm, so both flags together is fine. + if bool(getattr(payload, "confirm_tool_calls", False)) and not bool( + getattr(payload, "bypass_permissions", False) + ): raise HTTPException( status_code = 400, detail = anthropic_error_body( @@ -6984,6 +7005,7 @@ async def anthropic_messages( # Anthropic passthrough has no rag_scope field (RAG is local-only). rag_scope = getattr(payload, "rag_scope", None), disable_parallel_tool_use = _disable_parallel, + bypass_permissions = bool(payload.bypass_permissions), ) if payload.stream: diff --git a/studio/backend/tests/test_bypass_permissions.py b/studio/backend/tests/test_bypass_permissions.py new file mode 100644 index 0000000000..563f146816 --- /dev/null +++ b/studio/backend/tests/test_bypass_permissions.py @@ -0,0 +1,732 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Tests for Bypass Permissions (skip confirmation + disable sandbox). + +Covers the secret-name classifier, the two env builders, the +``disable_sandbox`` branch of ``_python_exec`` / ``_bash_exec`` (which env is +used, which pre-exec is used, and that safety checks / the blocklist are +skipped), the request-model default, the confirm-vs-bypass precedence rule the +route enforces, and that the agentic loop forwards ``disable_sandbox`` while +never gating under bypass. + +Run with: ``PYTHONPATH=studio/backend python -m pytest studio/backend/tests/test_bypass_permissions.py -q`` +""" + +import os +import sys + +import pytest + +import core.inference.tools as tools +from core.inference.tools import ( + _bash_exec, + _build_bypass_env, + _build_safe_env, + _is_cred_location_env_name, + _is_secret_env_name, + _is_secret_env_value, + _python_exec, +) +from core.inference.safetensors_agentic import run_safetensors_tool_loop + +_POSIX_ONLY = pytest.mark.skipif( + sys.platform == "win32", reason = "preexec_fn / setsid are POSIX-only" +) + + +# ── secret-name classifier ────────────────────────────────────────── + + +@pytest.mark.parametrize( + "name", + [ + "HF_TOKEN", + "HUGGING_FACE_HUB_TOKEN", + "WANDB_API_KEY", + "GH_TOKEN", + "GITHUB_TOKEN", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "AWS_SECRET_ACCESS_KEY", + "AWS_ACCESS_KEY_ID", + "AZURE_CLIENT_SECRET", + "GOOGLE_APPLICATION_CREDENTIALS", + "MY_DB_PASSWORD", + "x_api_key", + "SOME_PRIVATE_KEY", + "LD_PRELOAD", + ], +) +def test_secret_names_are_flagged(name): + assert _is_secret_env_name(name) is True + + +@pytest.mark.parametrize( + "name", ["PATH", "HOME", "LANG", "TERM", "PWD", "SHELL", "HOSTVAR", "MY_VAR"] +) +def test_benign_names_are_not_flagged(name): + assert _is_secret_env_name(name) is False + + +# ── env builders ──────────────────────────────────────────────────── + + +def test_bypass_env_keeps_benign_strips_secret_repoints_home(monkeypatch, tmp_path): + monkeypatch.setenv("HOSTVAR", "benign-123") + monkeypatch.setenv("HF_TOKEN", "secret-abc") + env = _build_bypass_env(str(tmp_path)) + assert env.get("HOSTVAR") == "benign-123" # full host env inherited + assert "HF_TOKEN" not in env # ...minus secrets + assert env["HOME"] == str(tmp_path) # $HOME-based cred lookups defused + assert env["TMPDIR"] == str(tmp_path) + + +def test_safe_env_excludes_host_and_secret(monkeypatch, tmp_path): + monkeypatch.setenv("HOSTVAR", "benign-123") + monkeypatch.setenv("HF_TOKEN", "secret-abc") + env = _build_safe_env(str(tmp_path)) + assert "HOSTVAR" not in env # whitelist build -> host vars never reach child + assert "HF_TOKEN" not in env + + +# ── Popen kwargs capture (no real execution) ──────────────────────── + + +class _FakeProc: + returncode = 0 + + def communicate(self, timeout = None): + return ("FAKEOUT", None) + + def poll(self): + return 0 + + def kill(self): + pass + + +@pytest.fixture +def captured_popen(monkeypatch): + cap = {} + + def fake_popen(cmd, **kwargs): + cap["cmd"] = cmd + cap["kwargs"] = kwargs + return _FakeProc() + + monkeypatch.setattr(tools.subprocess, "Popen", fake_popen) + return cap + + +@_POSIX_ONLY +def test_python_sandboxed_uses_sandbox_preexec_and_safe_env(captured_popen, monkeypatch): + monkeypatch.setenv("HF_TOKEN", "secret-abc") + _python_exec("print(1)", None, 5, "t", disable_sandbox = False) + assert captured_popen["kwargs"]["preexec_fn"] is tools._sandbox_preexec + assert "HF_TOKEN" not in captured_popen["kwargs"]["env"] + + +@_POSIX_ONLY +def test_python_bypass_uses_bypass_preexec_and_bypass_env(captured_popen, monkeypatch): + monkeypatch.setenv("HOSTVAR", "benign-xyz") + monkeypatch.setenv("HF_TOKEN", "secret-abc") + _python_exec("print(1)", None, 5, "t", disable_sandbox = True) + assert captured_popen["kwargs"]["preexec_fn"] is tools._bypass_preexec + env = captured_popen["kwargs"]["env"] + assert env.get("HOSTVAR") == "benign-xyz" + assert "HF_TOKEN" not in env + + +def test_bash_blocklist_enforced_when_sandboxed(captured_popen): + out = _bash_exec("rm -rf /", None, 5, "t", disable_sandbox = False) + assert "Blocked" in out + assert "cmd" not in captured_popen # never reached Popen + + +def test_bash_blocklist_skipped_when_bypassed(captured_popen): + out = _bash_exec("rm -rf /", None, 5, "t", disable_sandbox = True) + assert out == "FAKEOUT" # blocklist skipped -> reached (faked) execution + assert captured_popen["cmd"][0] in ("bash", "cmd") + + +@_POSIX_ONLY +def test_bash_bypass_uses_bypass_preexec(captured_popen): + _bash_exec("echo hi", None, 5, "t", disable_sandbox = True) + assert captured_popen["kwargs"]["preexec_fn"] is tools._bypass_preexec + + +# ── real end-to-end python execution under bypass ─────────────────── + + +@_POSIX_ONLY +def test_python_bypass_real_exec_sees_host_env_but_not_secret(monkeypatch): + monkeypatch.setenv("HOSTVAR", "benign-xyz") + monkeypatch.setenv("HF_TOKEN", "secret-pqr") + code = ( + "import os;" + "print('H=' + str(os.environ.get('HOSTVAR'))," + " 'T=' + str(os.environ.get('HF_TOKEN')))" + ) + out = _python_exec(code, None, 30, "test-bypass", disable_sandbox = True) + assert "H=benign-xyz" in out # unrestricted: real host var visible + assert "T=None" in out # ...but the secret was stripped + assert "secret-pqr" not in out + + +# ── _bypass_preexec is setsid-only (no rlimits) ───────────────────── + + +@_POSIX_ONLY +def test_bypass_preexec_only_sets_session(monkeypatch): + calls = {"setsid": 0} + monkeypatch.setattr( + tools.os, "setsid", lambda: calls.__setitem__("setsid", calls["setsid"] + 1) + ) + # _resource must not be touched by the bypass pre-exec. + if tools._resource is not None: + monkeypatch.setattr( + tools._resource, + "setrlimit", + lambda *a, **k: pytest.fail("bypass pre-exec must not set rlimits"), + ) + tools._bypass_preexec() + assert calls["setsid"] == 1 + + +# ── request model default ─────────────────────────────────────────── + + +def test_request_model_bypass_default_false(): + from models.inference import ChatCompletionRequest + assert ChatCompletionRequest.model_fields["bypass_permissions"].default is False + + +# ── confirm-vs-bypass precedence (mirrors the route rule) ─────────── + + +@pytest.mark.parametrize( + "confirm,bypass,effective_confirm", + [ + (False, False, False), + (True, False, True), + (False, True, False), + (True, True, False), + ], +) +def test_confirm_precedence_rule(confirm, bypass, effective_confirm): + # The route computes: confirm_tool_calls = confirm and not bypass. + assert (bool(confirm) and not bool(bypass)) is effective_confirm + + +# ── agentic loop forwards disable_sandbox, never gates under bypass ── + +_DEFAULT_TOOLS = [ + {"type": "function", "function": {"name": "python"}}, + {"type": "function", "function": {"name": "web_search"}}, +] + + +def _tool_call(name, args_json): + return f'{{"name": "{name}", "arguments": {args_json}}}' + + +def _multi_turn(turns): + it = iter(turns) + + def _gen(_messages): + try: + yield next(it) + except StopIteration: + return + + return _gen + + +def test_loop_forwards_disable_sandbox_and_does_not_gate(): + seen = [] + + def fake_exec( + name, + arguments, + *, + cancel_event = None, + timeout = None, + session_id = None, + rag_scope = None, + disable_sandbox = False, + ): + seen.append(disable_sandbox) + return f"RAN[{name}]" + + events = list( + run_safetensors_tool_loop( + single_turn = _multi_turn([_tool_call("python", '{"code": "x"}'), "done"]), + messages = [{"role": "user", "content": "hi"}], + tools = _DEFAULT_TOOLS, + execute_tool = fake_exec, + session_id = "s", + confirm_tool_calls = False, # route forces this off under bypass + bypass_permissions = True, + ) + ) + assert seen == [True] # disable_sandbox threaded through + starts = [e for e in events if e["type"] == "tool_start"] + assert starts and starts[0]["awaiting_confirmation"] is False + assert starts[0]["approval_id"] == "" + + +def test_loop_bypass_overrides_confirm_for_direct_callers(): + # Even if a direct internal caller passes confirm_tool_calls=True, bypass + # must suppress the confirm gate at the loop level (not only at the route). + def fake_exec( + name, + arguments, + *, + cancel_event = None, + timeout = None, + session_id = None, + rag_scope = None, + disable_sandbox = False, + ): + return f"RAN[{name}]" + + events = list( + run_safetensors_tool_loop( + single_turn = _multi_turn([_tool_call("python", '{"code": "x"}'), "done"]), + messages = [{"role": "user", "content": "hi"}], + tools = _DEFAULT_TOOLS, + execute_tool = fake_exec, + session_id = "s", + confirm_tool_calls = True, # raw caller leaves this on... + bypass_permissions = True, # ...but bypass must still win + ) + ) + starts = [e for e in events if e["type"] == "tool_start"] + assert starts and starts[0]["awaiting_confirmation"] is False + assert starts[0]["approval_id"] == "" + + +def test_gguf_loop_confirm_gate_respects_bypass(): + # The GGUF loop needs a live llama-server, so (per the other llama_cpp + # tests) assert via AST that its _needs_confirm gate applies the bypass + # precedence, mirroring the safetensors behavioral test above. + import ast + import inspect + import textwrap + + llama_cpp = pytest.importorskip("core.inference.llama_cpp") + src = textwrap.dedent( + inspect.getsource(llama_cpp.LlamaCppBackend.generate_chat_completion_with_tools) + ) + gates = [ + node + for node in ast.walk(ast.parse(src)) + if isinstance(node, ast.Assign) + and any(getattr(t, "id", None) == "needs_confirm" for t in node.targets) + ] + assert gates, "could not find the needs_confirm gate in the GGUF loop" + names = {n.id for g in gates for n in ast.walk(g.value) if isinstance(n, ast.Name)} + assert "confirm_tool_calls" in names + assert "bypass_permissions" in names # bypass must suppress the GGUF gate + + +# ── broker / capability env vars are stripped (regression) ────────── + + +@pytest.mark.parametrize( + "name", + ["SSH_AUTH_SOCK", "SSH_AGENT_PID", "GPG_AGENT_INFO", "GNUPGHOME", "KUBECONFIG"], +) +def test_broker_capability_names_are_flagged(name): + # Not secrets by value, but they hand the child the operator's live agent + # (ssh/gpg) or kube credentials, so bypass mode must drop them. + assert _is_secret_env_name(name) is True + + +# ── credential-bearing URL values stripped regardless of name ─────── + + +@pytest.mark.parametrize( + "value", + [ + "https://user:s3cr3t@feed.example.invalid/simple", # user:pass@ + "https://ghp_deadbeef@github.com/org/private.git", # token-only@ + "https://__token__@pypi.example.invalid/simple", + "https://ghp_1234:@npm.pkg.github.com/simple", # empty password + "postgres://dbuser:dbpass@db.example.invalid/app", + ], +) +def test_url_userinfo_values_are_flagged(value): + assert _is_secret_env_value(value) is True + + +@pytest.mark.parametrize( + "value", + [ + "https://example.invalid/simple", # no userinfo + "http://proxy.corp.example:8080", # benign proxy + "https://pypi.corp.example/simple", # benign internal index + "redis://localhost:6379/0", # no creds + "https://example.invalid/path?ref=a@b", # '@' only in query, not userinfo + ], +) +def test_non_credential_url_values_are_not_flagged(value): + assert _is_secret_env_value(value) is False + + +def test_url_userinfo_value_is_stripped_even_with_benign_name(monkeypatch, tmp_path): + # NAME dodges the classifier, but the VALUE embeds userinfo -> must go. + monkeypatch.setenv("MY_FEED", "https://user:s3cr3t@feed.example.invalid/simple") + monkeypatch.setenv("REPO_URL", "https://ghp_deadbeef@github.com/org/private.git") + # A URL without credentials is harmless and should be kept. + monkeypatch.setenv("PLAIN_URL", "https://example.invalid/simple") + env = _build_bypass_env(str(tmp_path)) + assert "MY_FEED" not in env + assert "REPO_URL" not in env + assert env.get("PLAIN_URL") == "https://example.invalid/simple" + + +def test_bypass_env_keeps_noncredential_proxy_and_index_urls(monkeypatch, tmp_path): + # Benign routing/config vars must survive bypass mode (proxy-only or + # internal-index networks); only credentialed values are dropped. + monkeypatch.setenv("HTTP_PROXY", "http://proxy.corp.example:8080") + monkeypatch.setenv("PIP_INDEX_URL", "https://pypi.corp.example/simple") + monkeypatch.setenv("PIP_EXTRA_INDEX_URL", "https://user:token@pypi.example.invalid/simple") + env = _build_bypass_env(str(tmp_path)) + assert env["HTTP_PROXY"] == "http://proxy.corp.example:8080" + assert env["PIP_INDEX_URL"] == "https://pypi.corp.example/simple" + assert "PIP_EXTRA_INDEX_URL" not in env # this one carries credentials + + +# ── AWS IMDS-disable hardening flag is kept (regression) ──────────── + + +def test_aws_imds_disable_flag_is_kept_but_creds_stripped(monkeypatch, tmp_path): + # AWS_EC2_METADATA_DISABLED is a non-secret opt-out: dropping it would let a + # bypassed boto/AWS-CLI call fall back to the instance role via IMDS even + # though the operator disabled that path. Keep it; drop the real creds. + monkeypatch.setenv("AWS_EC2_METADATA_DISABLED", "true") + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "shhh") + assert _is_secret_env_name("AWS_EC2_METADATA_DISABLED") is False + assert _is_secret_env_name("AWS_ACCESS_KEY_ID") is True + env = _build_bypass_env(str(tmp_path)) + assert env.get("AWS_EC2_METADATA_DISABLED") == "true" + assert "AWS_ACCESS_KEY_ID" not in env + assert "AWS_SECRET_ACCESS_KEY" not in env + + +# ── connection-string env vars are stripped (regression) ──────────── + + +@pytest.mark.parametrize( + "name", + [ + "SQLCONNSTR_DB", # Azure App Service injected connection strings + "MYSQLCONNSTR_DB", + "SQLAZURECONNSTR_DB", + "POSTGRESQLCONNSTR_DB", + "CUSTOMCONNSTR_CACHE", + "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING", + ], +) +def test_connection_string_names_are_flagged(name): + assert _is_secret_env_name(name) is True + + +@pytest.mark.parametrize( + "value", + [ + "Server=tcp:db;Database=app;User ID=u;Password=p@ss;", # ADO.NET + "DefaultEndpointsProtocol=https;AccountName=x;AccountKey=abc123==;", # storage + "Endpoint=sb://x;SharedAccessKeyName=n;SharedAccessKey=zzz=", # Service Bus + ], +) +def test_connection_string_values_are_flagged(value): + assert _is_secret_env_value(value) is True + + +@pytest.mark.parametrize( + "value", + [ + "Server=tcp:db;Database=app;User ID=u;", # no password field + "Endpoint=sb://x;SharedAccessKeyName=n", # key NAME only, no secret + "AccountName=x;EndpointSuffix=core.windows.net", # no AccountKey + ], +) +def test_connection_string_noncredential_values_are_not_flagged(value): + assert _is_secret_env_value(value) is False + + +def test_connection_string_value_stripped_even_with_benign_name(monkeypatch, tmp_path): + # NAME dodges the classifier, but the VALUE is a credentialed conn string. + monkeypatch.setenv("APP_DB", "Server=tcp:db;Database=app;User ID=u;Password=p@ss;") + monkeypatch.setenv("SQLCONNSTR_DB", "DefaultEndpointsProtocol=https;AccountKey=abc==") + env = _build_bypass_env(str(tmp_path)) + assert "APP_DB" not in env # value-based catch + assert "SQLCONNSTR_DB" not in env # name-based catch + + +# ── temp dirs repointed on every platform (regression) ────────────── + + +def test_bypass_env_repoints_all_temp_vars(monkeypatch, tmp_path): + # Windows tempfile honours TEMP/TMP, not TMPDIR; all three must repoint. + monkeypatch.setenv("TEMP", "/host/tmp") + monkeypatch.setenv("TMP", "/host/tmp") + env = _build_bypass_env(str(tmp_path)) + assert env["TMPDIR"] == str(tmp_path) + assert env["TEMP"] == str(tmp_path) + assert env["TMP"] == str(tmp_path) + + +# ── credential-location redirect vars are dropped (regression) ────────── +# Vars that point SDKs at the real home/cache/config (cached tokens), e.g. +# HF_HOME which startup always sets -> the live leak the HOME repoint missed. + + +@pytest.mark.parametrize( + "name", + [ + "HF_HOME", + "HF_HUB_CACHE", + "HUGGINGFACE_HUB_CACHE", + "HF_XET_CACHE", + "TRANSFORMERS_CACHE", + "HF_DATASETS_CACHE", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "NETRC", + "BOTO_CONFIG", + "PIP_CONFIG_FILE", + "CLOUDSDK_CONFIG", + "KAGGLE_CONFIG_DIR", + "DOCKER_CONFIG", + "WANDB_DIR", + "WANDB_CONFIG_DIR", + "NPM_CONFIG_USERCONFIG", + "NPM_CONFIG_GLOBALCONFIG", + "YARN_RC_FILENAME", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + "CARGO_HOME", + "RCLONE_CONFIG", + "GIT_ASKPASS", + "SSH_ASKPASS", + "BASH_ENV", + "HOMEDRIVE", + "HOMEPATH", + ], +) +def test_cred_location_names_are_flagged(name): + assert _is_cred_location_env_name(name) is True + + +@pytest.mark.parametrize("name", ["PATH", "HOME", "LANG", "PWD", "MY_VAR"]) +def test_benign_names_not_flagged_as_cred_location(name): + assert _is_cred_location_env_name(name) is False + + +def test_bypass_env_drops_hf_home_so_cached_token_unreachable(monkeypatch, tmp_path): + # The live leak: startup sets HF_HOME at the real cache, whose $HF_HOME/token + # holds the operator's token. Repointing HOME does not stop huggingface_hub + # from reading $HF_HOME/token, so HF_HOME must be dropped in bypass mode. + real_cache = tmp_path / "real_hf_cache" + real_cache.mkdir() + (real_cache / "token").write_text("hf_cachedOperatorToken") + monkeypatch.setenv("HF_HOME", str(real_cache)) + monkeypatch.setenv("HF_HUB_CACHE", str(real_cache / "hub")) + env = _build_bypass_env(str(tmp_path)) + assert "HF_HOME" not in env # dropped -> HF falls back to $HOME/.cache (empty) + assert "HF_HUB_CACHE" not in env + + +def test_bypass_env_hf_token_resolves_outside_real_cache(monkeypatch, tmp_path): + # End-to-end: even when HF_HOME and XDG_CACHE_HOME both point at the real + # cache, the bypass env must make huggingface_hub resolve the token under the + # workdir (guards the XDG fallback chain, not just "HF_HOME absent"). + pytest.importorskip("huggingface_hub") + import subprocess + + real_cache = tmp_path / "real_hf" + real_cache.mkdir() + workdir = tmp_path / "sandbox" + workdir.mkdir() + monkeypatch.setenv("HF_HOME", str(real_cache)) + monkeypatch.setenv("XDG_CACHE_HOME", str(real_cache)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(real_cache)) + env = _build_bypass_env(str(workdir)) + token_path = subprocess.run( + [ + sys.executable, + "-c", + "import huggingface_hub.constants as c; print(c.HF_TOKEN_PATH)", + ], + env = env, + capture_output = True, + text = True, + ).stdout.strip() + assert str(real_cache) not in token_path # never the operator's cache + assert token_path.startswith(str(workdir)) # resolved under the sandbox + + +def test_bypass_env_drops_credential_config_path_vars(monkeypatch, tmp_path): + # NETRC / BOTO_CONFIG / PIP_CONFIG_FILE point clients at real credential + # files before $HOME, so they must not survive into the bypassed child. + monkeypatch.setenv("NETRC", "/home/op/.netrc") + monkeypatch.setenv("PGPASSFILE", "/home/op/.pgpass") + monkeypatch.setenv("BOTO_CONFIG", "/home/op/.boto") + monkeypatch.setenv("PIP_CONFIG_FILE", "/home/op/.pip/pip.conf") + env = _build_bypass_env(str(tmp_path)) + assert "NETRC" not in env + assert "PGPASSFILE" not in env + assert "BOTO_CONFIG" not in env + assert "PIP_CONFIG_FILE" not in env + + +def test_bypass_env_strips_npm_auth_and_mysql_pwd(monkeypatch, tmp_path): + # NPM_CONFIG__AUTH (npm _auth, base64) and MYSQL_PWD dodge the URL-value + # check and the PASSWD marker, but must still be dropped. + monkeypatch.setenv("NPM_CONFIG__AUTH", "aGVsbG86c2VjcmV0") + monkeypatch.setenv("MYSQL_PWD", "db-password") + assert _is_secret_env_name("NPM_CONFIG__AUTH") is True + assert _is_secret_env_name("MYSQL_PWD") is True + env = _build_bypass_env(str(tmp_path)) + assert "NPM_CONFIG__AUTH" not in env + assert "MYSQL_PWD" not in env + + +@_POSIX_ONLY +def test_bash_bypass_does_not_source_bash_env(monkeypatch, tmp_path): + # bash -c sources $BASH_ENV for non-interactive shells; an operator startup + # file could re-export stripped secrets, so a real bypass call must not see it. + startup = tmp_path / "startup.sh" + startup.write_text("export RECOVERED=leaked\n") + monkeypatch.setenv("BASH_ENV", str(startup)) + out = _bash_exec("echo R=$RECOVERED", None, 30, "bash-env-test", disable_sandbox = True) + assert "R=leaked" not in out # BASH_ENV dropped -> startup not sourced + assert "R=" in out + + +def test_bypass_env_repoints_windows_profile_vars(monkeypatch, tmp_path): + # On Windows, SDKs read cached creds under USERPROFILE/APPDATA/LOCALAPPDATA, + # not $HOME. Set ones are repointed at the workdir; HOMEDRIVE/HOMEPATH drop. + monkeypatch.setenv("USERPROFILE", "/host/profile") + monkeypatch.setenv("APPDATA", "/host/profile/AppData/Roaming") + monkeypatch.setenv("LOCALAPPDATA", "/host/profile/AppData/Local") + monkeypatch.setenv("HOMEDRIVE", "C:") + monkeypatch.setenv("HOMEPATH", "\\Users\\op") + env = _build_bypass_env(str(tmp_path)) + assert env["USERPROFILE"] == str(tmp_path) + assert env["APPDATA"] == str(tmp_path) + assert env["LOCALAPPDATA"] == str(tmp_path) + assert "HOMEDRIVE" not in env + assert "HOMEPATH" not in env + + +def test_bypass_env_does_not_add_unset_windows_profile_vars(monkeypatch, tmp_path): + # Only repoint Windows profile vars that were actually set (no pollution on + # Linux/macOS where they are absent). + monkeypatch.delenv("USERPROFILE", raising = False) + monkeypatch.delenv("APPDATA", raising = False) + monkeypatch.delenv("LOCALAPPDATA", raising = False) + env = _build_bypass_env(str(tmp_path)) + assert "USERPROFILE" not in env + assert "APPDATA" not in env + assert "LOCALAPPDATA" not in env + + +# ── parent /proc env-leak hardening (regression) ──────────────────── + + +@_POSIX_ONLY +def test_bypass_exec_hardens_parent_proc_env(monkeypatch, captured_popen): + # Stripping the child env is not enough: a same-UID child can read the + # parent's /proc environ. The exec paths must invoke the parent hardening + # when (and only when) the sandbox is disabled. + calls = {"n": 0} + + def fake_harden(): + calls["n"] += 1 + return True + + monkeypatch.setattr(tools, "_harden_parent_against_proc_env_leak", fake_harden) + _python_exec("print(1)", None, 5, "t", disable_sandbox = True) + _bash_exec("echo hi", None, 5, "t", disable_sandbox = True) + assert calls["n"] == 2 + + calls["n"] = 0 + _python_exec("print(1)", None, 5, "t", disable_sandbox = False) + _bash_exec("echo hi", None, 5, "t", disable_sandbox = False) + assert calls["n"] == 0 # never hardened on the sandboxed path + + +def test_bypass_exec_fails_closed_when_hardening_fails(monkeypatch, captured_popen): + # If the parent cannot be hardened (e.g. prctl denied), the unsandboxed + # child must NOT run - otherwise the parent environ stays readable. + monkeypatch.setattr(tools, "_harden_parent_against_proc_env_leak", lambda: False) + out_py = _python_exec("print(1)", None, 5, "t", disable_sandbox = True) + out_sh = _bash_exec("echo hi", None, 5, "t", disable_sandbox = True) + assert "refusing bypass execution" in out_py + assert "refusing bypass execution" in out_sh + assert "cmd" not in captured_popen # never reached Popen + + +@_POSIX_ONLY +def test_proc_env_unreadable_after_hardening(): + # Mechanism check: after hardening, a same-UID child can no longer read the + # parent process /proc environ. Restores the dumpable flag afterwards so the + # process-global state does not leak into later tests. + import subprocess + + if tools._libc is None: + pytest.skip("no libc/prctl available") + pid = os.getpid() + probe = ( + "try:\n" + f" open('/proc/{pid}/environ', 'rb').read()\n" + " print('READABLE')\n" + "except PermissionError:\n" + " print('DENIED')\n" + ) + prev_dumpable = tools._libc.prctl(3, 0, 0, 0, 0) # PR_GET_DUMPABLE + prev_guard = tools._parent_proc_hardened + try: + # Establish a clean readable baseline: another test may have already + # cleared the dumpable flag on this process. + tools._libc.prctl(4, 1, 0, 0, 0) # PR_SET_DUMPABLE = 1 + before = subprocess.run( + [sys.executable, "-c", probe], capture_output = True, text = True + ).stdout.strip() + if before != "READABLE": + pytest.skip("/proc already restricted in this environment") + + tools._parent_proc_hardened = False + assert tools._harden_parent_against_proc_env_leak() is True + + after = subprocess.run( + [sys.executable, "-c", probe], capture_output = True, text = True + ).stdout.strip() + assert after == "DENIED" + finally: + if prev_dumpable in (0, 1): + try: + tools._libc.prctl(4, prev_dumpable, 0, 0, 0) + except (OSError, AttributeError): + pass + tools._parent_proc_hardened = prev_guard + + +# ── Anthropic request model declares the field (regression) ───────── + + +def test_anthropic_request_model_bypass_default_false(): + # Omitting the field on the Anthropic path must default to False rather than + # raising AttributeError (extra='allow' does not set absent attributes). + from models.inference import AnthropicMessagesRequest + + assert AnthropicMessagesRequest.model_fields["bypass_permissions"].default is False + req = AnthropicMessagesRequest(model = "x", messages = [], max_tokens = 8) + assert bool(req.bypass_permissions) is False diff --git a/studio/backend/tests/test_safetensors_tool_loop.py b/studio/backend/tests/test_safetensors_tool_loop.py index 12731783a0..024d1801c0 100644 --- a/studio/backend/tests/test_safetensors_tool_loop.py +++ b/studio/backend/tests/test_safetensors_tool_loop.py @@ -208,6 +208,7 @@ class FakeExecuteTool: timeout = None, session_id = None, rag_scope = None, + disable_sandbox = False, ): self.calls.append((name, arguments)) result = self.results.pop(0) if self.results else "OK" diff --git a/studio/backend/tests/test_tool_confirm_loop.py b/studio/backend/tests/test_tool_confirm_loop.py index ce7852c95f..17ef697674 100644 --- a/studio/backend/tests/test_tool_confirm_loop.py +++ b/studio/backend/tests/test_tool_confirm_loop.py @@ -43,6 +43,7 @@ class _FakeExecuteTool: timeout = None, session_id = None, rag_scope = None, + disable_sandbox = False, ): self.calls.append((name, arguments)) return f"RESULT[{name}]" diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 839c44d940..ec252b74a8 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -67,6 +67,7 @@ import { parseExternalModelId } from "@/features/chat/external-providers"; import { McpComposerButton } from "@/features/chat/mcp-composer-button"; import { getExternalReasoningCapabilities } from "@/features/chat/provider-capabilities"; import { useRagToolDisabled } from "@/features/chat/hooks/use-rag-tool-disabled"; +import { BypassPermissionsMenuItem } from "@/features/chat/bypass-permissions-menu-item"; import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; import { useExternalProvidersStore } from "@/features/chat/stores/external-providers-store"; import { @@ -1223,6 +1224,9 @@ const Composer: FC<{ data-pill-compact={pillsCompact ? "true" : undefined} > + {/* Active-mode badge: always visible when bypass is on, even while + the pill row is collapsed (returns null when off). */} + {composerExpanded ? ( <> @@ -1931,6 +1935,30 @@ const ArtifactsToggle: FC = () => { ); }; +// Red pill shown while Bypass Permissions is on; click to turn it off. +// Mirror of shared-composer's badge so both composers surface the state. +const BypassPermissionsToggle: FC = () => { + const bypassPermissions = useChatRuntimeStore((s) => s.bypassPermissions); + const setBypassPermissions = useChatRuntimeStore( + (s) => s.setBypassPermissions, + ); + if (!bypassPermissions) return null; + return ( + + ); +}; + const ToolStatusDisplay: FC = () => { const toolStatus = useChatRuntimeStore((s) => s.toolStatus); const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); @@ -2276,6 +2304,7 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({ ) : null} ), + bypassPermissions: , projects: ( diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 6122b51dfd..c1a618862e 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -1551,6 +1551,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { artifactsEnabled, mcpEnabledForChat, confirmToolCalls, + bypassPermissions, webFetchToolsEnabled, ragEnabled, ragSource, @@ -2483,7 +2484,10 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { : []), ], mcp_enabled: mcpEnabledForChat, - confirm_tool_calls: confirmToolCalls, + // Bypass Permissions wins: never request the confirm gate + // while bypassing, and tell the backend to drop the sandbox. + confirm_tool_calls: confirmToolCalls && !bypassPermissions, + bypass_permissions: bypassPermissions, // Scope: thread_id = this thread's docs, kb_id = a KB, // project_id = the thread's project sources (auto-on whenever // the project has indexed sources, no Docs pill needed). diff --git a/studio/frontend/src/features/chat/bypass-permissions-menu-item.tsx b/studio/frontend/src/features/chat/bypass-permissions-menu-item.tsx new file mode 100644 index 0000000000..824d946d06 --- /dev/null +++ b/studio/frontend/src/features/chat/bypass-permissions-menu-item.tsx @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { ShieldOffIcon } from "lucide-react"; +import { useState } from "react"; + +import { HugeiconsIcon } from "@hugeicons/react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; +import { Tick02Icon } from "@/lib/tick-icon"; + +// "Bypass Permissions" entry for the composer "+" -> More menu. Mirrors the +// settings toggle: enabling demands the danger warning, disabling is immediate. +// onSelect preventDefault keeps the menu mounted so the warning dialog (which +// lives in this same fragment) survives instead of unmounting with the menu. +export function BypassPermissionsMenuItem() { + const bypassPermissions = useChatRuntimeStore((s) => s.bypassPermissions); + const setBypassPermissions = useChatRuntimeStore( + (s) => s.setBypassPermissions, + ); + const [dialogOpen, setDialogOpen] = useState(false); + + return ( + <> + { + if (bypassPermissions) { + setBypassPermissions(false); + } else { + e.preventDefault(); + setDialogOpen(true); + } + }} + > + + Bypass Permissions + {bypassPermissions ? ( + + ) : null} + + + + + Enable Bypass Permissions? + + Bypass Permissions is dangerous since the AI model might delete, + corrupt your machine, and or cause real world damage to you or the + world - only accept if you are certain + + + + Cancel + { + setBypassPermissions(true); + setDialogOpen(false); + }} + > + I understand + + + + + + ); +} diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index c69265bf59..d6b5d658da 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -6,6 +6,16 @@ import { AlertDescription, AlertTitle, } from "@/components/ui/alert"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -1527,6 +1537,7 @@ export function ChatSettingsPanel({
+
@@ -1713,27 +1724,99 @@ function AutoHealToolCallsToggle() { function ConfirmToolCallsToggle() { const confirmToolCalls = useChatRuntimeStore((s) => s.confirmToolCalls); const setConfirmToolCalls = useChatRuntimeStore((s) => s.setConfirmToolCalls); + const bypassPermissions = useChatRuntimeStore((s) => s.bypassPermissions); return (
-
- - Confirm tool calls - - - When on, local Studio tool calls pause for your approval before they - run. Provider-hosted tools are not gated here. - +
+
+ + Confirm tool calls + + + When on, local Studio tool calls pause for your approval before they + run. Provider-hosted tools are not gated here. + +
+ {bypassPermissions ? ( + + Overridden by Bypass Permissions + + ) : null}
); } +function BypassPermissionsToggle() { + const bypassPermissions = useChatRuntimeStore((s) => s.bypassPermissions); + const setBypassPermissions = useChatRuntimeStore( + (s) => s.setBypassPermissions, + ); + const [dialogOpen, setDialogOpen] = useState(false); + + return ( +
+
+
+ + Bypass Permissions + + + Dangerous. Runs every tool call with no confirmation and disables + the python/terminal sandbox. Environment secrets are stripped, but + code can still read files and credentials on your machine. + +
+ { + if (next) setDialogOpen(true); + else setBypassPermissions(false); + }} + /> +
+ {bypassPermissions ? ( + + Tool calls run with no confirmation and no sandbox. + + ) : null} + + + + Enable Bypass Permissions? + + Bypass Permissions is dangerous since the AI model might delete, + corrupt your machine, and or cause real world damage to you or the + world - only accept if you are certain + + + + Cancel + { + setBypassPermissions(true); + setDialogOpen(false); + }} + > + I understand + + + + +
+ ); +} + function ChatTemplateFields() { const defaultTemplate = useChatRuntimeStore((s) => s.defaultChatTemplate); const override = useChatRuntimeStore((s) => s.chatTemplateOverride); diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 8f547f8307..40063705ab 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -59,6 +59,7 @@ import { } from "./prompt-storage/prompt-storage-dialog"; import { listPromptEntries, type PromptEntry } from "./api/prompts-api"; import { McpComposerButton } from "./mcp-composer-button"; +import { BypassPermissionsMenuItem } from "./bypass-permissions-menu-item"; import { KnowledgeBaseComposerButton } from "@/features/rag/components/knowledge-base-composer-button"; import { NewProjectDialog } from "./components/new-project-dialog"; import { useChatProjects } from "./hooks/use-chat-projects"; @@ -533,6 +534,10 @@ export function SharedComposer({ const setWebFetchToolsEnabled = useChatRuntimeStore( (s) => s.setWebFetchToolsEnabled, ); + const bypassPermissions = useChatRuntimeStore((s) => s.bypassPermissions); + const setBypassPermissions = useChatRuntimeStore( + (s) => s.setBypassPermissions, + ); const ragEnabled = useChatRuntimeStore((s) => s.ragEnabled); const setRagEnabled = useChatRuntimeStore((s) => s.setRagEnabled); const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId); @@ -1240,6 +1245,7 @@ export function SharedComposer({ ) : null} ), + bypassPermissions: , projects: ( @@ -1684,6 +1690,20 @@ export function SharedComposer({ ) : null} {mcpEnabledForChat ? : null} + {bypassPermissions && ( + + )}
{/* mr-0.5 matches the send button inset from the edge in normal chat; gap-1.5 matches its control spacing. */} diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index c2f3578803..003a32ddb1 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -35,6 +35,7 @@ export const CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY = "unsloth_chat_allow_artifact_network_access"; export const CHAT_MCP_ENABLED_KEY = "unsloth_chat_mcp_enabled"; export const CHAT_CONFIRM_TOOL_CALLS_KEY = "unsloth_chat_confirm_tool_calls"; +export const CHAT_BYPASS_PERMISSIONS_KEY = "unsloth_chat_bypass_permissions"; export const CHAT_WEB_FETCH_TOOLS_ENABLED_KEY = "unsloth_chat_web_fetch_tools_enabled"; export const CHAT_RAG_SOURCE_KEY = "unsloth_chat_rag_source"; @@ -487,6 +488,12 @@ type ChatRuntimeStore = { * chat before they run. */ confirmToolCalls: boolean; + /** + * Bypass Permissions: when on, tool calls run with no confirmation gate + * AND the python/terminal execution sandbox is disabled on the backend + * (secrets are still stripped). Takes precedence over confirmToolCalls. + */ + bypassPermissions: boolean; /** * Per-chat set of tool names the user chose to auto-approve via "Always * allow". Keyed by UI confirmation scope, not necessarily the backend @@ -606,6 +613,7 @@ type ChatRuntimeStore = { setAllowArtifactNetworkAccess: (enabled: boolean) => void; setMcpEnabledForChat: (enabled: boolean) => void; setConfirmToolCalls: (enabled: boolean) => void; + setBypassPermissions: (enabled: boolean) => void; allowToolAlways: (sessionId: string, toolName: string) => void; setToolConfirmation: ( toolCallId: string, @@ -883,6 +891,10 @@ export const useChatRuntimeStore = create((set, get) => ({ ), mcpEnabledForChat: loadBool(CHAT_MCP_ENABLED_KEY, false), confirmToolCalls: loadBool(CHAT_CONFIRM_TOOL_CALLS_KEY, false), + // Never restore Bypass Permissions from storage: it disables the sandbox and + // the confirmation gate, so it must be re-enabled (through the warning + // dialog) each session rather than silently reactivating on reload. + bypassPermissions: false, alwaysAllowToolsBySession: new Map>(), toolConfirmations: {}, webFetchToolsEnabled: loadBool(CHAT_WEB_FETCH_TOOLS_ENABLED_KEY, false), @@ -1225,6 +1237,10 @@ export const useChatRuntimeStore = create((set, get) => ({ saveBool(CHAT_CONFIRM_TOOL_CALLS_KEY, confirmToolCalls); return { confirmToolCalls }; }), + setBypassPermissions: (bypassPermissions) => + // Deliberately not persisted (see init): a reload must not silently keep + // the sandbox/confirmation bypass active without re-accepting the warning. + set(() => ({ bypassPermissions })), allowToolAlways: (sessionId, toolName) => set((state) => { const current = state.alwaysAllowToolsBySession.get(sessionId); diff --git a/studio/frontend/src/features/chat/stores/plus-menu-prefs-store.ts b/studio/frontend/src/features/chat/stores/plus-menu-prefs-store.ts index f94e3814e6..822c66eeb5 100644 --- a/studio/frontend/src/features/chat/stores/plus-menu-prefs-store.ts +++ b/studio/frontend/src/features/chat/stores/plus-menu-prefs-store.ts @@ -14,7 +14,8 @@ export type PlusMenuItemId = | "compareChat" | "exportChat" | "canvas" - | "projects"; + | "projects" + | "bypassPermissions"; // Canonical order used both for the pinned items at the top level and for the // items that fall into the "More" overflow submenu. @@ -26,6 +27,7 @@ export const PLUS_MENU_ORDER: PlusMenuItemId[] = [ "exportChat", "canvas", "projects", + "bypassPermissions", ]; // Defaults reproduce the historical layout: Chat with Files, MCP and Projects @@ -38,6 +40,8 @@ const DEFAULT_PINS: Record = { compareChat: false, exportChat: false, canvas: false, + // Lives under "More" by default; it is a rarely toggled, dangerous mode. + bypassPermissions: false, }; export interface PlusMenuPrefsState { diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css index d0ca2d2426..118d5f9383 100644 --- a/studio/frontend/src/index.css +++ b/studio/frontend/src/index.css @@ -1046,6 +1046,11 @@ .composer-pill-btn[data-active="true"] { color: var(--primary); } + /* Bypass Permissions badge: red, always-visible active warning. */ + .composer-pill-btn[data-variant="danger"] { + @apply bg-destructive/10 hover:bg-destructive/15; + color: var(--destructive); + } /* With more than 4 tools on, drop pill labels to icons only to cut clutter. Compare keeps its label via data-keep-label. */