unsloth/studio/backend/tests/test_tool_confirm_loop.py
Daniel Han ca0528d1f8
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>
2026-06-15 04:04:22 -07:00

171 lines
5.4 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
"""Integration tests for the confirmation gate inside the real tool loop.
These drive ``run_safetensors_tool_loop`` (no model -- hand-crafted fake
generators) with ``confirm_tool_calls=True`` and resolve each pending
decision inline. The slot is registered before ``tool_start`` is yielded,
so resolving right after receiving that event always lands before the
loop blocks. Covers: allow executes once, deny skips execution and feeds
back the rejection, disabled/duplicate calls are not prompted, and a
denied call does not pollute duplicate detection.
"""
import pytest
from core.inference.safetensors_agentic import run_safetensors_tool_loop
from state import tool_approvals
from state.tool_approvals import TOOL_REJECTED_MESSAGE, resolve_tool_decision
_SESSION = "loop-session"
@pytest.fixture(autouse = True)
def _clear_pending():
with tool_approvals._lock:
tool_approvals._pending.clear()
yield
with tool_approvals._lock:
tool_approvals._pending.clear()
class _FakeExecuteTool:
def __init__(self):
self.calls = []
def __call__(
self,
name,
arguments,
*,
cancel_event = None,
timeout = None,
session_id = None,
rag_scope = None,
disable_sandbox = False,
):
self.calls.append((name, arguments))
return f"RESULT[{name}]"
def _tool_call(name, args_json):
return f'<tool_call>{{"name": "{name}", "arguments": {args_json}}}</tool_call>'
def _multi_turn(turns):
"""A single_turn generator that yields one full snapshot per turn."""
turn_iter = iter(turns)
def _gen(_messages):
try:
yield next(turn_iter)
except StopIteration:
return
return _gen
_DEFAULT_TOOLS = [
{"type": "function", "function": {"name": "python"}},
{"type": "function", "function": {"name": "web_search"}},
]
def _drive(
turns,
decisions,
*,
tools = None,
):
"""Run the loop, resolving each gated tool_start with the next decision.
The advertised ``tools`` list drives the loop's enabled-tool filter
(pass a list omitting a tool to make a call to it "disabled").
Returns (events, execute_calls).
"""
decision_iter = iter(decisions)
exec_fn = _FakeExecuteTool()
gen = run_safetensors_tool_loop(
single_turn = _multi_turn(turns),
messages = [{"role": "user", "content": "hi"}],
tools = _DEFAULT_TOOLS if tools is None else tools,
execute_tool = exec_fn,
session_id = _SESSION,
confirm_tool_calls = True,
)
events = []
for ev in gen:
events.append(ev)
if ev["type"] == "tool_start" and ev.get("awaiting_confirmation"):
# Slot is already registered (begin ran before this yield), so
# the decision lands before the loop enters its blocking wait.
resolve_tool_decision(ev["approval_id"], next(decision_iter), session_id = _SESSION)
return events, exec_fn.calls
def _tool_starts(events):
return [e for e in events if e["type"] == "tool_start"]
def _tool_ends(events):
return [e for e in events if e["type"] == "tool_end"]
def test_allow_executes_the_tool_once():
events, calls = _drive(
[_tool_call("python", '{"code": "print(1)"}'), "final answer"],
["allow"],
)
starts = _tool_starts(events)
assert len(starts) == 1
assert starts[0]["awaiting_confirmation"] is True
assert starts[0]["approval_id"]
assert calls == [("python", {"code": "print(1)"})]
assert _tool_ends(events)[0]["result"] == "RESULT[python]"
def test_deny_skips_execution_and_feeds_rejection():
events, calls = _drive(
[_tool_call("python", '{"code": "print(1)"}'), "final answer"],
["deny"],
)
assert calls == [] # tool never ran
assert _tool_ends(events)[0]["result"] == TOOL_REJECTED_MESSAGE
def test_disabled_tool_is_not_prompted():
events, calls = _drive(
[_tool_call("python", '{"code": "print(1)"}'), "final answer"],
[],
tools = [{"type": "function", "function": {"name": "web_search"}}],
)
assert _tool_starts(events) == []
assert _tool_ends(events) == []
assert calls == []
def test_duplicate_call_is_not_prompted():
same = _tool_call("python", '{"code": "print(1)"}')
events, calls = _drive([same, same, "final answer"], ["allow"])
starts = _tool_starts(events)
assert len(starts) == 1
assert starts[0]["awaiting_confirmation"] is True
assert calls == [("python", {"code": "print(1)"})]
assert len(_tool_ends(events)) == 1
def test_denied_call_can_be_reissued_and_approved():
# Deny, then the model re-issues the identical call -> approving it must
# execute, not get suppressed as a duplicate (denied calls are not added
# to the duplicate-detection history).
same = _tool_call("python", '{"code": "print(1)"}')
events, calls = _drive([same, same, "final answer"], ["deny", "allow"])
starts = _tool_starts(events)
assert len(starts) == 2
assert starts[0]["awaiting_confirmation"] is True
assert starts[1]["awaiting_confirmation"] is True # not treated as dup
assert calls == [("python", {"code": "print(1)"})] # ran once, on approve
ends = _tool_ends(events)
assert ends[0]["result"] == TOOL_REJECTED_MESSAGE
assert ends[1]["result"] == "RESULT[python]"