e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
737 lines
27 KiB
Python
737 lines
27 KiB
Python
# 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 env.get("PYTHONIOENCODING") == "utf-8"
|
|
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, monkeypatch):
|
|
# bypass inherits benign host vars; clear so we assert _bash_exec adds none.
|
|
monkeypatch.delenv("PYTHONIOENCODING", raising = False)
|
|
_bash_exec("echo hi", None, 5, "t", disable_sandbox = True)
|
|
assert captured_popen["kwargs"]["preexec_fn"] is tools._bypass_preexec
|
|
assert "PYTHONIOENCODING" not in captured_popen["kwargs"]["env"]
|
|
|
|
|
|
# ── 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'<tool_call>{{"name": "{name}", "arguments": {args_json}}}</tool_call>'
|
|
|
|
|
|
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
|