* 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>
171 lines
5.4 KiB
Python
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]"
|