Studio: Bypass Permissions (skip confirmation, disable tool sandbox) (#5895)
* Studio: Add inline confirmation (Allow/Always allow/Deny) for tool calls * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix race in tool-call confirmation gate * Studio: gate built-in tool calls and harden the confirmation handshake The Allow / Always allow / Deny controls only lived in the fallback tool card, but the built-in tools (web search, python, terminal, code execution, image generation) render with their own components and so never showed the buttons. Those calls paused after tool_start with no way to approve them, hanging until the 1 hour timeout. Only MCP tools, which use the fallback renderer, actually worked. Render the controls for every tool card by wrapping each registered tool component (and the fallback) in thread.tsx with a shared ToolConfirmationControls, so the gate applies uniformly. Also make the handshake robust: - The gate keys on a per-call approval_id minted by the backend and echoed in tool_start, instead of session_id alone, so a stale or concurrent confirmation can no longer resolve the wrong call. - The approval slot is registered before tool_start is yielded, closing the race where a fast click or an auto "Always allow" could reach the backend before the waiter existed. - The frontend resolves with the same session id the request was sent with (plus the approval_id), fixing the new-thread mismatch where the confirmation targeted a different session than the blocked stream. - The confirm endpoint returns {resolved}; the UI keeps the buttons and shows a retry hint until the backend confirms a match, instead of hiding them on a failed or mistargeted post. - The gate runs after the disabled-tool and duplicate-call checks, so a call that will not execute is not put up for approval. A denied call is still excluded from duplicate detection, so re-issuing and approving it works. - "Always allow" is scoped per session to match the backend gate. Add backend tests for the approval registry, the SSE no-deadlock handshake, and the loop integration (allow, deny, disabled, duplicate, re-issue after deny). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Move "Confirm tool calls" to the Tools section * Studio: add Bypass Permissions (skip confirmation, disable tool sandbox) Adds an opt-in Bypass Permissions toggle next to Confirm tool calls. When on, no tool call shows a confirmation prompt and the python/terminal sandbox is disabled: safety checks, command blocklist, and resource limits are skipped. Secret env vars are still stripped and HOME stays repointed at the session workdir. Default off keeps current behavior, and it takes precedence over Confirm tool calls. Enabling it requires accepting a warning each time. * Studio: harden Bypass Permissions secret handling and fix Anthropic tool path Follow-up to the Bypass Permissions feature. Addresses the review findings: - Anthropic /v1/messages 500: declare bypass_permissions on AnthropicMessagesRequest so tool requests that omit the field default to False instead of raising AttributeError (extra='allow' does not set absent attributes). - /proc parent-env leak: stripping the child env did not stop a same-uid bypassed child from reading /proc/<parent>/environ to recover the tool-executing process's unfiltered secrets. Clear PR_SET_DUMPABLE on that process before the first bypass exec so its /proc entries become root-owned. Hardening is fail-closed: if prctl is denied, bypass execution is refused rather than run with the parent environ still readable. Mitigation, not a full boundary; documented in the code. - Broker/capability vars: strip SSH_AUTH_SOCK, SSH_AGENT_PID, GPG_AGENT_INFO, GNUPGHOME, KUBECONFIG, DOCKER_HOST so a bypassed tool cannot use the operator's live agents. - Credential-bearing URL values: drop any env var whose value embeds URL userinfo (scheme://user:pass@ and token-only scheme://token@) regardless of the variable name. Benign proxy/index URLs without credentials are kept, so proxy-only and internal-index setups still work in bypass mode. - Windows temp isolation: repoint TEMP and TMP (not just TMPDIR) at the per-session sandbox dir. - Frontend: stop persisting bypassPermissions; a reload now starts with the sandbox/confirmation bypass off and requires re-accepting the warning dialog. Adds regression tests for each finding in test_bypass_permissions.py. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: strip cred-location env vars (HF_HOME etc.) in Bypass Permissions Repointing HOME did not stop SDKs auto-reading cached creds via vars that point at the real home/cache/config: HF_HOME (startup always sets it; token lives under $HF_HOME/token), HF/XDG cache roots, NETRC/BOTO_CONFIG/ PIP_CONFIG_FILE, and Windows HOMEDRIVE/HOMEPATH. Drop those, and repoint USERPROFILE/APPDATA/LOCALAPPDATA at the per-session workdir. Adds regression tests. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: lock in bypass HF token resolution with an end-to-end test The drop-based fix relies on the whole HF_HOME/XDG fallback chain being removed so huggingface_hub resolves under the repointed HOME. Add a test that sets HF_HOME and XDG_CACHE_HOME at a real cache and asserts the resolved token path lands under the workdir, not the operator's cache. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: strip npm _auth, MYSQL_PWD, and BASH_ENV from bypass env Three more credential vectors dodged the bypass scrubber: NPM_CONFIG__AUTH (npm _auth, base64 so no URL userinfo and no AUTH marker), MYSQL_PWD (markers use PASSWD, not PWD, since PWD is the cwd var), and BASH_ENV (bash -c sources it for non-interactive shells, so a startup file can re-export stripped secrets). Add an AUTH marker, the exact MYSQL_PWD name, and drop BASH_ENV plus PGPASSFILE. Adds regression tests incl. an end-to-end check that a bypass terminal call does not source BASH_ENV. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: extend bypass env scrubber and enforce confirm precedence in loops From a parallel review pass over the bypass changes: - Drop more credential-location vars in _build_bypass_env: npm/yarn/git/cargo/ rclone config pointers (NPM_CONFIG_USERCONFIG, NPM_CONFIG_GLOBALCONFIG, YARN_RC_FILENAME, GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM, CARGO_HOME, RCLONE_CONFIG) and the GIT_ASKPASS/SSH_ASKPASS auth helpers. - Enforce confirm_tool_calls AND NOT bypass_permissions inside the safetensors and GGUF tool loops, not just at the route, so a direct internal caller passing both flags never prompts. - Soften the toggle hint: environment secrets are stripped, but bypassed code can still read files and credentials on the machine (no overclaim that keys stay hidden). Adds regression tests for the new names and the loop-level precedence. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: add GGUF loop test for bypass-over-confirm precedence The safetensors loop precedence is covered behaviorally; the GGUF loop needs a live llama-server so add an AST guard asserting its _needs_confirm gate references both confirm_tool_calls and bypass_permissions, matching the other llama_cpp source-inspection tests. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: add red Bypass Permissions badge in the composer When Bypass Permissions is on, show a persistent red pill in the composer tool-pill row (like the Search/Code pills), matching Claude Code's always- visible bypass indicator. Clicking it turns bypass off, mirroring the other composer toggles. Enabling still goes through the settings toggle + warning dialog. Adds a data-variant=danger style for the destructive-colored pill. * Studio: show Bypass Permissions badge in the Thread composer too The empty-state and active Thread render their own composer (thread.tsx), not shared-composer, so the badge only appeared in the split layout. Mirror the red dismissible pill in ComposerAction so it shows in every composer. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: keep the Bypass Permissions badge visible when the composer is collapsed The Thread composer only renders the pill row when expanded, so the active-mode badge vanished on the default (collapsed) empty state. Render it before the expand gate (it returns null when bypass is off) so the red indicator always shows while bypass is on. * Studio: make the Bypass Permissions confirm button a solid red button The destructive button variant is a subtle 10% tint that read as bare red text next to the outlined Cancel. Force the solid destructive fill (the variant's class loses to the tint through AlertDialogAction's Slot merge, so use the ! override the codebase already uses for this case) and shorten the label to 'I understand' so it fits the small dialog's two-column footer. * Studio: add Bypass Permissions to the composer + More menu Adds a 'Bypass Permissions' entry to the composer plus-menu (under More by default) in both composers, so it can be toggled without opening Run settings. Enabling routes through the same danger warning dialog; disabling is immediate. A shared BypassPermissionsMenuItem keeps the two composers in sync. * Studio: harden bypass env scrubber for IMDS opt-out and connection strings Two gaps in the Bypass Permissions secret scrubber: - The broad AWS_ prefix also dropped AWS_EC2_METADATA_DISABLED, a non-secret opt-out. Removing it re-opens the IMDS instance-role credential path that the operator explicitly disabled, so a bypassed boto/AWS-CLI call could recover cloud creds. Keep that flag (and AWS_EC2_METADATA_V1_DISABLED) via a keep-list while still stripping the real AWS credential vars. - Azure App Service connection strings (SQLCONNSTR_/CUSTOMCONNSTR_/..., WEBSITE_CONTENTAZUREFILECONNECTIONSTRING) and values like Password=/AccountKey= /SharedAccessKey= slipped past the name and URL-only value classifiers. Add CONNSTR/CONNECTIONSTRING name markers and a connection-string value matcher. * Studio: let Bypass Permissions suppress the confirm-tool-calls guards The confirm-vs-bypass precedence (confirm and not bypass) was applied at the loop call sites but not at the earlier request guards, so a client sending confirm_tool_calls + bypass_permissions together was rejected (stream=true required / unsupported for external or Anthropic tools) before the precedence took effect. Gate all four confirm guards on not bypass_permissions so both flags together proceed with the gate suppressed, matching the documented rule. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
930dd17086
commit
ca0528d1f8
17 changed files with 1348 additions and 40 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/<pid>/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/<getppid()>/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/<pid>/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/<parent>/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/<parent>/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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
732
studio/backend/tests/test_bypass_permissions.py
Normal file
732
studio/backend/tests/test_bypass_permissions.py
Normal file
|
|
@ -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'<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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}]"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
<ComposerToolsMenu side={effectiveMenuSide} />
|
||||
{/* Active-mode badge: always visible when bypass is on, even while
|
||||
the pill row is collapsed (returns null when off). */}
|
||||
<BypassPermissionsToggle />
|
||||
{composerExpanded ? (
|
||||
<>
|
||||
<WebSearchToggle />
|
||||
|
|
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBypassPermissions(false)}
|
||||
className="composer-pill-btn"
|
||||
data-active="true"
|
||||
data-variant="danger"
|
||||
aria-label="Disable Bypass Permissions"
|
||||
title="Bypass Permissions is on (no confirmation, no sandbox). Click to turn off."
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
<span>Bypass Permissions</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
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}
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
bypassPermissions: <BypassPermissionsMenuItem />,
|
||||
projects: (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
className={
|
||||
bypassPermissions ? "text-destructive font-medium" : undefined
|
||||
}
|
||||
onSelect={(e) => {
|
||||
if (bypassPermissions) {
|
||||
setBypassPermissions(false);
|
||||
} else {
|
||||
e.preventDefault();
|
||||
setDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ShieldOffIcon />
|
||||
Bypass Permissions
|
||||
{bypassPermissions ? (
|
||||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="ml-auto" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
<AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Enable Bypass Permissions?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
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
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
className="!bg-destructive !text-destructive-foreground hover:!bg-destructive/90"
|
||||
onClick={() => {
|
||||
setBypassPermissions(true);
|
||||
setDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
I understand
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
|||
<div className="flex flex-col gap-5 pt-1">
|
||||
<AutoHealToolCallsToggle />
|
||||
<ConfirmToolCallsToggle />
|
||||
<BypassPermissionsToggle />
|
||||
<MaxToolCallsSlider />
|
||||
<ToolCallTimeoutSlider />
|
||||
</div>
|
||||
|
|
@ -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 (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
|
||||
Confirm tool calls
|
||||
</span>
|
||||
<InfoHint>
|
||||
When on, local Studio tool calls pause for your approval before they
|
||||
run. Provider-hosted tools are not gated here.
|
||||
</InfoHint>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
|
||||
Confirm tool calls
|
||||
</span>
|
||||
<InfoHint>
|
||||
When on, local Studio tool calls pause for your approval before they
|
||||
run. Provider-hosted tools are not gated here.
|
||||
</InfoHint>
|
||||
</div>
|
||||
{bypassPermissions ? (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Overridden by Bypass Permissions
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
className="panel-switch"
|
||||
checked={confirmToolCalls}
|
||||
checked={confirmToolCalls && !bypassPermissions}
|
||||
onCheckedChange={setConfirmToolCalls}
|
||||
disabled={bypassPermissions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BypassPermissionsToggle() {
|
||||
const bypassPermissions = useChatRuntimeStore((s) => s.bypassPermissions);
|
||||
const setBypassPermissions = useChatRuntimeStore(
|
||||
(s) => s.setBypassPermissions,
|
||||
);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
|
||||
Bypass Permissions
|
||||
</span>
|
||||
<InfoHint>
|
||||
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.
|
||||
</InfoHint>
|
||||
</div>
|
||||
<Switch
|
||||
className="panel-switch"
|
||||
checked={bypassPermissions}
|
||||
onCheckedChange={(next) => {
|
||||
if (next) setDialogOpen(true);
|
||||
else setBypassPermissions(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{bypassPermissions ? (
|
||||
<span className="text-[11px] text-destructive">
|
||||
Tool calls run with no confirmation and no sandbox.
|
||||
</span>
|
||||
) : null}
|
||||
<AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Enable Bypass Permissions?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
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
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
className="!bg-destructive !text-destructive-foreground hover:!bg-destructive/90"
|
||||
onClick={() => {
|
||||
setBypassPermissions(true);
|
||||
setDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
I understand
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatTemplateFields() {
|
||||
const defaultTemplate = useChatRuntimeStore((s) => s.defaultChatTemplate);
|
||||
const override = useChatRuntimeStore((s) => s.chatTemplateOverride);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
bypassPermissions: <BypassPermissionsMenuItem />,
|
||||
projects: (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
|
|
@ -1684,6 +1690,20 @@ export function SharedComposer({
|
|||
</button>
|
||||
) : null}
|
||||
{mcpEnabledForChat ? <McpComposerButton side="top" /> : null}
|
||||
{bypassPermissions && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBypassPermissions(false)}
|
||||
className="composer-pill-btn"
|
||||
data-active="true"
|
||||
data-variant="danger"
|
||||
aria-label="Disable Bypass Permissions"
|
||||
title="Bypass Permissions is on (no confirmation, no sandbox). Click to turn off."
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
<span>Bypass Permissions</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* mr-0.5 matches the send button inset from the edge in normal chat;
|
||||
gap-1.5 matches its control spacing. */}
|
||||
|
|
|
|||
|
|
@ -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<ChatRuntimeStore>((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<string, Set<string>>(),
|
||||
toolConfirmations: {},
|
||||
webFetchToolsEnabled: loadBool(CHAT_WEB_FETCH_TOOLS_ENABLED_KEY, false),
|
||||
|
|
@ -1225,6 +1237,10 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((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);
|
||||
|
|
|
|||
|
|
@ -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<PlusMenuItemId, boolean> = {
|
|||
compareChat: false,
|
||||
exportChat: false,
|
||||
canvas: false,
|
||||
// Lives under "More" by default; it is a rarely toggled, dangerous mode.
|
||||
bypassPermissions: false,
|
||||
};
|
||||
|
||||
export interface PlusMenuPrefsState {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue