# 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 permission_mode ("Ask for approval" / "Approve for me" / "Off" / "Full access") permission levels. Covers the auto-mode safety classifier in tools.py and the loop-level behavior of run_safetensors_tool_loop: in "auto" mode only calls detected as potentially unsafe pause for confirmation, in "full" mode nothing pauses and the sandbox is dropped, and unset/unknown modes behave as "ask" (every call pauses when confirm_tool_calls is on). """ import os import uuid import pytest from core.inference.mcp_client import MCP_TOOL_PREFIX from core.inference.safetensors_agentic import run_safetensors_tool_loop from core.inference.tools import is_potentially_unsafe_tool_call from models.inference import AnthropicMessagesRequest, ChatCompletionRequest from state import tool_approvals from state.tool_approvals import resolve_tool_decision _SESSION = "perm-mode-session" @pytest.fixture(autouse = True) def _isolate_permission_mode_globals(): """Keep the loop-driving tests hermetic against process-global state that leaks across the full backend suite. ``run_safetensors_tool_loop`` reads a process-global approval registry (``state.tool_approvals._pending``) and honors ``os.environ``. Other test modules mutate both (module-level ``os.environ[...] = ...`` runs at import time; abandoned approvals can survive a test). A stale entry keyed by the shared session id, or a leaked env var, can make the loop deny or skip a call that these tests expect to run, which only surfaces in the full-suite ordering on CI (not when the file runs alone). Snapshot and restore both, and hand every ``_drive`` call a unique session, so each test starts clean. """ env_snapshot = dict(os.environ) with tool_approvals._lock: pending_snapshot = dict(tool_approvals._pending) tool_approvals._pending.clear() try: yield finally: with tool_approvals._lock: tool_approvals._pending.clear() tool_approvals._pending.update(pending_snapshot) os.environ.clear() os.environ.update(env_snapshot) @pytest.fixture(autouse = True) def _clear_pending(): with tool_approvals._lock: tool_approvals._pending.clear() yield with tool_approvals._lock: tool_approvals._pending.clear() # ── classifier ────────────────────────────────────────────────────── @pytest.mark.parametrize( ("command", "unsafe"), [ ("ls -la", False), ("cat foo.txt | grep hello", False), ("find . -name '*.py' | head -5", False), ("env FOO=1 grep -r pattern .", False), ("echo hi > out.txt", True), # write redirection ("rm -rf /", True), ("ls; rm x", True), # unsafe after separator ("xargs rm", True), # xargs is not a safe wrapper: it injects stdin args ("xargs sort", True), # forwards to sort with unscanned stdin arguments ("echo -o out x | xargs sort", True), # hidden write via stdin-supplied args ("find . -name '*.py' | xargs grep foo", True), # xargs run stays gated ("ionice -c 3 -p 1234", True), # -p changes a running process's IO priority ("ionice -p 1", True), ("ionice -P 999", True), # -P targets a process group ("ionice -u 1000", True), # -u targets a user's processes ("ionice -c3 -p1234", True), # attached short flags still target a process ("ionice -c 3 ls", False), # a real wrapped command stays safe ("ionice -n 5 grep x .", False), # class-data flag then wrapped read stays safe ("sudo ls", True), ("git push origin main", True), ("pip install requests", True), ("echo `whoami`", True), # substitution fails closed ("python -c 'print(1)'", True), # arbitrary code ("find . -exec rm {} ;", True), # find can execute ("find . -delete", True), # find can delete ("fd -x rm", True), # fd runs a command per result ("fd --exec-batch rm", True), ("fd -e py pattern", False), # plain fd search stays read only ("sort -o out.txt in.txt", True), # -o writes a file ("sort --output=out in", True), ("sort --compress-program=sh big.txt", True), # runs an external program ("sort -T ./scratch large.txt", True), # -T writes temporaries to a chosen dir ("sort --temporary-directory=./s big.txt", True), ("sort in.txt", False), # plain sort stays read only ("rg --pre sh needle f.sh", True), # rg preprocessor runs a command ("rg --pre=/tmp/x needle .", True), ("rg --hostname-bin /tmp/x foo .", True), ("rg --pre-glob '*.txt' needle .", False), # glob filter stays read only ("rg needle .", False), # plain rg stays read only ("/tmp/cat secrets", True), # path-qualified command is an arbitrary binary ("./ls -la", True), ("env /tmp/cat x", True), # path-qualified target after a wrapper ("tree -o out.txt", True), # -o writes a file ("time -o /tmp/r ls", True), # GNU time -o truncates a file ("time --output=/tmp/r ls", True), # GNU time long output flag ("command time -o/tmp/result cat /dev/null", True), # attached, behind command ("time -a log.txt ls", True), # GNU time append flag ("time ls", False), # plain time wrapper stays safe ("time -p ls", False), # POSIX time -p (no file) stays safe ("xxd -r dump.hex out.bin", True), # -r can write ("xxd input.bin dump.hex", True), # 2nd positional is the outfile ("xxd -c 16 in.bin out.hex", True), # outfile past a numeric flag value ("xxd input.bin", False), # single positional reads to stdout ("xxd -c 16 input.bin", False), # flag value is not a second file ("xxd 42 99", True), # digit-named outfile positional still counts ("xxd -s 0x10 input.bin", False), # seek value is not a second file ("awk '{print}' file", True), # awk can system()/write ("grep -o x file", False), # grep -o is stdout only ("ls\nrm -rf x", True), # newline separates commands ("ls\r\nrm x", True), # CRLF separates commands ("ls\n\n\nrm x", True), # blank lines collapse to one separator ("ls\npwd", False), # multi-line stays safe when every line is ("ls\n", False), ("sort -o/tmp/out /tmp/in", True), # attached short output flag ("sort -uo out.txt in.txt", True), # -o bundled in a short cluster ("sort -bo out in", True), ("sort -u in.txt", False), # cluster without a write flag stays safe ("find . \\( -name x -delete \\)", True), # -delete inside a group ("cat ../../.ssh/id_rsa", True), # parent traversal read ("cat ~/.aws/credentials", True), # credential path ("cat /home/a/.azure/msal_token_cache.json", True), # azure token store ("cat ~/.config/gh/hosts.yml", True), # gh cli credentials ("cat ~/.config/app/settings.json", False), # ordinary config stays safe ("cat /home/alice/.cache/huggingface/token", True), # HF login token ("cat ~/.cache/huggingface/stored_tokens", True), # HF multi-token store ("cat /home/alice/.huggingface/token", True), # legacy HF token location ("cat /home/alice/myhuggingface/token", False), # unrelated dir stays safe ( "cat /home/alice/.cache/huggingface/hub/models--x/config.json", False, ), # HF model cache is not a credential ("cat /run/secrets/hf_token", True), # docker secret mount ("cat /var/run/secrets/kubernetes.io/serviceaccount/token", True), # k8s mount ("cat /run/app.pid", False), # ordinary /run file stays safe ("cat /etc/passwd", True), # sensitive system file ("cat /proc/self/environ", True), # procfs env dump ("cat /proc/1/cmdline", True), ("head /proc/self/maps", True), ("cat /proc/self/fd/3", True), # procfs fd symlink to an open file ("cat /proc/1234/task/1234/fd/3", True), # per-thread fd symlink ("LD_PRELOAD=/tmp/hook.so ls", True), # code-loading env prefix ("PATH=. ls", True), # command-lookup env prefix ("IFS=x ls", True), ("FOO=1 grep -r x .", False), # benign env prefix stays safe ("ps auxe", True), # ps can dump process env; not on the safe list ("ps aux", True), ("cd /; cat etc/passwd", True), # cd escapes the workdir ("cd subdir; ls", True), # cd is no longer auto-approved ("env --chdir=/ cat etc/passwd", True), # env -C escapes the workdir ("env -S 'sh -c id' true", True), # env --split-string builds a command ("env FOO=1 grep -r x .", False), # benign env wrapper stays safe ("cat /etc//passwd", True), # redundant slashes resolve to /etc/passwd ("cat /etc/./passwd", True), ("p=/etc; cat $p/passwd", True), # path split across an assignment ("d=/etc; cat ${d}/shadow", True), ("FOO=1 echo $FOO", False), # benign variable expansion stays safe ("cat /proc/$PPID/enviro''n", True), # quote-split procfs read ("cat /proc/self/'environ'", True), ('p="/proc/$PPID"; cat $p/environ', True), # quoted+nested var procfs ("LESSOPEN='|touch x; cat %s' less f.txt", True), # less input preprocessor ("less file.txt", True), # less pager escapes (+cmd, !shell, -o) so it asks ("less '+!touch pwned' notes.txt", True), # less +command runs a shell command ("more file.txt", True), # more shares the !shell pager escape ("cat /proc/cpuinfo", False), # non-sensitive procfs read stays safe ("cat /e??/passwd", True), # glob expands to /etc/passwd ("cat /e[t]c/passwd", True), # bracket class hides etc ("head /etc/shado?", True), ("cat /et\\c/passwd", True), # backslash escape hides /etc/passwd ("cat /etc/pass\\wd", True), ("ls *.py", False), # benign glob stays safe ("head data?.txt", False), ("grep -R TOKEN /home", True), # recursive search escapes the workdir ("rg TOKEN /", True), ("fd pattern /etc", True), ("grep -r foo src/", False), # sandbox-relative search stays safe ("rg TOKEN .", False), ("tree /home", True), # always-recursive walker escapes onto host files ("du /", True), # disk-usage walk of the whole host root ("du -sh /home", True), # summarized host-home walk still recurses ("ls -R /home", True), # ls recurses with -R onto host files ("ls -R /etc", True), ("ls -laR /", True), # -R inside a short cluster still recurses ("tree .", False), # cwd walk stays in the sandbox ("tree ./project", False), # relative walk stays safe ("du -sh", False), # du with no path defaults to cwd ("du -sh ./build", False), # relative disk-usage stays safe ("ls -R subdir", False), # relative recursive listing stays safe ("ls -la /home", False), # non-recursive listing of one level stays here ("sort --files0-from=list.txt", True), # reads an indirect file list ("sort --files0-from list.txt", True), # separate-value form ("sort -u data.txt", False), # ordinary sort stays read only ("wc --files0-from=list", True), # wc reads an indirect file list too ("wc --files0-from list", True), ("du --files0-from=list", True), # du indirect file list ("find -files0-from list", True), # find primary reading a file list ("wc file.txt", False), # ordinary wc stays read only ("wc -l data.txt", False), # counting flag stays read only ("cat logs/app.log", False), # ordinary relative read ("cat /r?n/secrets/hf_token", True), # glob into a secret mount ("cat /var/r?n/secrets/db", True), ("cat /root/.s??/id_rsa", True), # glob into a credential dir ("cat ~/.huggingface/tok?n", True), # glob resolves to a credential basename ("cat proj/.netr?", True), # glob resolves to .netrc anywhere ("cat repo/.aws/cred*", True), # glob resolves to credentials anywhere ("cat backup/id_rs?", True), # glob resolves to id_rsa anywhere ("cat .e?v", True), # glob resolves to a project .env secret ("cat proj/.en?", True), # .env anywhere via a glob ("cat notes/dra?t.txt", False), # benign globbed basename stays safe ("cat data/token_counts.tx?", False), # 'token' prefix basename stays safe ("ls /home/*/projects", False), # benign glob not into a cred dir ("grep -R TOKEN ~root", True), # tilde-user recursive root escapes ("grep -R TOKEN ~/logs", True), # tilde-home recursive root escapes ("cat /etc/pass{w,}d", True), # brace expansion builds /etc/passwd ("cat report{1,2}.txt", False), # benign brace stays safe ("cat /e{t,}c/pass?d", True), # brace-expanded candidate then a glob resolves it ("cat /et{c,}/pass?d", True), # brace + glob in the tail ("cat repo/d{1,2}/f?.txt", False), # benign brace + glob stays safe ("cat /etc/pass${x:-wd}", True), # default param expansion builds path ("cat /etc/pass${x:=wd}", True), ("echo ${x:-hello}", False), # benign default param stays safe ("cat Report

Summary

") is False assert ( rh("
") is False ) assert rh("") is False assert rh("") is False assert rh("") is True assert rh("") is True assert rh("") is True assert rh("") is True assert rh("") is True # Worker / SharedWorker constructors run an off-thread script the scan cannot # see (a module worker from a CORS CDN, or a blob/same-origin worker that # fetches/importScripts) under worker-src http: https: blob:, so they ask. assert rh("") is True assert rh("") is True assert rh("") is True assert rh("") is False # not a ctor assert rh("") is False # unrelated class, not a real Worker # Resource-loading forms beyond a direct fetch also reach the network. assert rh("") is True assert rh("") is True assert rh("") is True assert rh("") is True # root-relative resolves to origin assert rh("") is True # protocol-relative # Self-navigation sinks exfiltrate by navigating the frame away. assert rh("") is True assert rh("") is True assert rh("") is True assert rh("") is True assert rh("") is True assert rh("") is False # reload is not navigation assert rh("") is False # Obfuscated egress: a block comment splitting fetch(, or bracket access. assert rh("") is True assert rh("") is True # A computed bracket key spliced from string fragments on a global host object. assert rh("") is True assert rh("") is True # A computed key on a plain object (not a global host) stays a static canvas. assert rh("") is False assert rh("") is False # comment only # A meta-refresh with a url navigates the frame to an external origin. assert rh('') is True assert rh("") is True assert rh('') is False # self-reload, no url assert rh('

Hi

') is False # ordinary meta stays safe def test_unknown_tools_fail_closed(): assert is_potentially_unsafe_tool_call("mystery_tool", {}) is True def test_is_always_safe_tool(): from core.inference.tools import is_always_safe_tool for name in ("web_search", "search_knowledge_base"): assert is_always_safe_tool(name) is True # render_html is no longer unconditionally safe: a networked canvas can prompt, # which cannot be judged before its arguments stream. for name in ("python", "terminal", "mystery_tool", "mcp__srv__read", "render_html"): assert is_always_safe_tool(name) is False @pytest.mark.parametrize( ("tool", "unsafe"), [ ("get_weather", False), ("list_files", False), ("search", False), ("send_email", True), ("create_issue", True), ("delete_row", True), ("get_or_create_issue", True), # mutating verb overrides read prefix ("read_and_delete_file", True), ("find_and_update_row", True), ("get_and_commit_changes", True), # commit/save/archive are mutating ("read_and_save_file", True), ("list_and_archive", True), ("list_and_clone_repo", True), # clone/checkout/comment are mutating ("fetch_and_comment_issue", True), ("get_and_checkout_branch", True), ("read_and_append_file", True), # append/prepend are mutating ("prepend_line", True), ("get_and_upsert_row", True), # upsert/assign are mutating ("list_and_assign_issue", True), ("read_and_copy_file", True), # copy-style verbs create/overwrite state ("get_and_copy_resource", True), ("read_and_duplicate_entry", True), ("fetch_and_download_asset", True), # download writes local state ("list_and_export_data", True), # import/export/backup/restore/snapshot ("get_and_snapshot_volume", True), ("get_and_mark_read", True), # mark/subscribe change external state ("get_and_subscribe", True), ("list_and_unsubscribe", True), ("get_and_reply_email", True), # reply/notify send/change external state ("list_and_notify_users", True), ("read_secret", True), # credential noun: a read that discloses a secret ("list_tokens", True), ("get_credentials", True), ("fetch_api_key", True), # scoped *_key noun ("read_access_key", True), ("get_password", True), ("read_passphrase", True), ("read_report", False), # plain read stays safe ("get_primary_key", False), # a schema key is not a credential ("search_keyboard_shortcuts", False), # 'key' inside another word stays safe ("list_bookmarks", False), # 'mark' substring in a token stays safe ("list_notifications", False), # 'notify' is a different token than 'notifications' ], ) def test_mcp_classifier(tool, unsafe): name = f"{MCP_TOOL_PREFIX}srv1__{tool}" assert is_potentially_unsafe_tool_call(name, {}) is unsafe @pytest.mark.parametrize( ("args", "unsafe"), [ ({"path": "/etc/passwd"}, True), # read-named tool at a credential path ({"path": "../../.ssh/id_rsa"}, True), ({"nested": {"file": "~/.aws/credentials"}}, True), ({"name": "OPENAI_API_KEY"}, True), # explicit credential env-var read ({"name": "AWS_SECRET_ACCESS_KEY"}, True), ({"key": "DATABASE_PASSWORD"}, True), ( {"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}, True, ), # AWS instance-metadata host ( {"url": "http://metadata.google.internal/computeMetadata/v1/"}, True, ), # GCP metadata host ({"path": "notes.txt"}, False), # ordinary path stays safe ({"path": "data/report.csv"}, False), ({"name": "PATH"}, False), # a non-secret env var stays safe ({"name": "HOME"}, False), ({"url": "https://example.com/api"}, False), # ordinary URL stays safe ({"url": "http://localhost:8080/health"}, False), # localhost app stays safe ], ) def test_mcp_sensitive_arguments(args, unsafe): name = f"{MCP_TOOL_PREFIX}fs__read_file" assert is_potentially_unsafe_tool_call(name, args) is unsafe @pytest.mark.parametrize( ("args", "unsafe"), [ ({"query": "DELETE FROM runs"}, True), # read-named tool, mutating query ({"sql": "DROP TABLE users"}, True), ({"query": "UPDATE t SET x=1"}, True), ({"query": "INSERT INTO t VALUES (1)"}, True), ({"query": "SELECT * FROM runs"}, False), # read query stays safe ({"query": "how to delete old files"}, False), # NL text with 'delete' stays safe ({"query": "find the created_at column"}, False), # 'created' substring stays safe ({"query": "DELETE/**/FROM runs"}, True), # inline SQL comment as whitespace ({"query": "UPDATE/**/t SET x=1"}, True), ({"query": "DROP/**/TABLE users"}, True), ({"query": "SELECT * FROM runs -- delete later"}, False), # trailing comment stays safe ({"query": "COPY users FROM '/tmp/u.csv'"}, True), # bulk load writes the table ({"query": "COPY users (id, name)\nFROM STDIN"}, True), # multiline COPY FROM ({"query": "COPY (SELECT 1) TO '/tmp/o.csv'"}, True), # COPY TO writes a server file ({"query": "SELECT copy_count FROM t"}, False), # 'copy' substring column stays safe ({"query": "mutation { deleteIssue(id: 1) }"}, True), # GraphQL mutation ({"query": "mutation DelIssue { deleteIssue(id: 1) }"}, True), # named GraphQL mutation ({"query": "mutation # note\n { deleteIssue(id: 1) }"}, True), # comment before body ({"query": "mutation # c\n Del { deleteIssue(id: 1) }"}, True), # comment before name ({"query": "query { issue(id: 1) { title } }"}, False), # GraphQL read query stays safe ({"query": "{ issue(id: 1) { title } }"}, False), # shorthand GraphQL query stays safe ({"query": "query # note\n { issue(id: 1) }"}, False), # commented read query stays safe ({"query": "CREATE OR REPLACE VIEW v AS SELECT 1"}, True), # DDL with a modifier ({"query": "CREATE UNIQUE INDEX idx ON t(x)"}, True), # DDL with UNIQUE ({"query": "CREATE TEMP TABLE t (id int)"}, True), # DDL with TEMP ({"query": "CREATE MATERIALIZED VIEW mv AS SELECT 1"}, True), # materialized view DDL ({"query": "CREATE FUNCTION f() RETURNS int AS $$ $$"}, True), # function DDL ({"query": "ALTER SYSTEM SET work_mem = '1GB'"}, True), # persists server config ({"query": "alter system reset all"}, True), # ALTER SYSTEM RESET ({"query": "SELECT * FROM system_logs"}, False), # 'system' as a table name stays safe ({"query": "SELECT * FROM created_view"}, False), # 'create' substring stays safe ({"query": "CALL delete_all_users()"}, True), # stored procedure invocation ({"query": "EXEC purge_queue"}, True), # EXEC procedure ({"query": "EXECUTE sp_drop"}, True), # EXECUTE procedure ({"query": "VACUUM INTO 'backup.db'"}, True), # VACUUM rewrites the database ({"query": "please call me back later"}, False), # NL 'call' stays safe ({"query": "ATTACH DATABASE '/tmp/x.db' AS x"}, True), # attaches a database file ({"query": "DETACH DATABASE x"}, True), # detaches a database ({"query": "PRAGMA user_version = 42"}, True), # write-form PRAGMA ({"query": "PRAGMA journal_mode=WAL"}, True), # write-form PRAGMA (no spaces) ({"query": "PRAGMA foreign_keys(0)"}, True), # call-form PRAGMA write ({"query": "SELECT load_extension('/tmp/evil.so')"}, True), # loads native code ({"query": "PRAGMA journal_mode"}, False), # read-form PRAGMA stays safe ({"query": "can you attach the report to the email"}, False), # NL 'attach' stays safe ({"query": "ATTACH '/tmp/x.db' AS x"}, True), # ATTACH without DATABASE keyword ({"query": "PRAGMA main.user_version = 1"}, True), # schema-qualified write PRAGMA ({"query": "attach it as draft"}, False), # NL 'attach ... as' stays safe ({"query": "DROP FUNCTION f()"}, True), # DROP of a non-table object ({"query": "ALTER INDEX idx RENAME TO idx2"}, True), # ALTER of a non-table object ({"query": "DROP MATERIALIZED VIEW mv"}, True), # DROP with a modifier ({"query": "ALTER USER bob WITH PASSWORD 'x'"}, True), # ALTER USER mutates ({"query": "SELECT dropped_at FROM t"}, False), # 'drop' substring column stays safe ({"query": "mutation M @audit { deleteIssue(id: 1) }"}, True), # directive GraphQL mutation ( {"query": "query Q @cached { issue(id: 1) { title } }"}, False, ), # directive GraphQL read stays safe ({"query": 'UPDATE "users" SET admin=1'}, True), # double-quoted UPDATE target ({"query": "UPDATE public.users SET admin=1"}, True), # schema-qualified UPDATE ({"query": "UPDATE ONLY public.users SET admin=1"}, True), # ONLY-qualified UPDATE ({"query": "UPDATE `users` SET admin=1"}, True), # backtick-quoted UPDATE ({"query": "UPDATE [users] SET admin=1"}, True), # bracket-quoted UPDATE ({"query": "please update the documentation set"}, False), # NL 'update ... set' stays safe ({"query": "SELECT pg_terminate_backend(123)"}, True), # state-changing SQL function ({"query": "SELECT setval('s', 1)"}, True), # sequence mutation function ({"query": "SELECT pg_write_file('/tmp/p', 'x')"}, True), # server-side file write ({"query": "SELECT lo_export(123, '/tmp/p')"}, True), # large-object export to a file ({"query": "SELECT setval_col FROM t"}, False), # 'setval' column prefix stays safe ( {"query": "SELECT secret INTO OUTFILE '/tmp/leak' FROM users"}, True, ), # INTO OUTFILE write ({"query": "SELECT x INTO DUMPFILE '/tmp/d' FROM t"}, True), # INTO DUMPFILE write ( {"query": "SELECT count(*) INTO cnt FROM t"}, False, ), # PL/pgSQL SELECT INTO var stays safe ({"query": "REFRESH MATERIALIZED VIEW mv"}, True), # materialized view rewrite ({"query": "REINDEX INDEX idx"}, True), # index rebuild ({"query": "REINDEX TABLE t"}, True), # table reindex ({"query": "SELECT refresh_count FROM t"}, False), # 'refresh' column stays safe ({"query": "please refresh the page"}, False), # NL 'refresh' stays safe ({"query": "COMMENT ON TABLE users IS 'owned'"}, True), # catalog metadata write ({"query": "LOCK TABLE users IN ACCESS EXCLUSIVE MODE"}, True), # explicit lock ({"query": "SECURITY LABEL FOR x ON TABLE t IS 'z'"}, True), # security label write ({"query": "CREATE POLICY p ON accounts USING (true)"}, True), # row-security policy DDL ({"query": "SELECT comment FROM t"}, False), # 'comment' column stays safe ({"query": "SELECT * FROM locks"}, False), # 'locks' table stays safe ({"query": "SELECT nextval('billing_seq')"}, True), # sequence advance mutates ({"query": "SELECT pg_advisory_lock(42)"}, True), # advisory lock changes state ({"query": "SELECT pg_notify('jobs', 'wake')"}, True), # server-side notification ({"query": "SELECT set_config('x', 'y', false)"}, True), # session config write ({"query": "SELECT nextval_col FROM t"}, False), # 'nextval' column prefix stays safe ({"query": "TRUNCATE users"}, True), # multi-char table name (bare TRUNCATE) ({"query": "TRUNCATE TABLE accounts"}, True), # multi-char TRUNCATE TABLE ({"query": 'TRUNCATE TABLE "users"'}, True), # quoted TRUNCATE target ({"query": "TRUNCATE accounts RESTART IDENTITY"}, True), # TRUNCATE with options ({"query": "SELECT truncate_log FROM t"}, False), # 'truncate' column stays safe ({"query": "UPDATE users AS u SET admin=1"}, True), # aliased UPDATE target (AS) ({"query": 'UPDATE "users" AS u SET x=1'}, True), # quoted+aliased UPDATE ({"query": "UPDATE public.users AS u SET x=1"}, True), # schema-qualified aliased UPDATE ({"query": "SELECT * FROM users AS u"}, False), # aliased SELECT stays safe ({"query": "please update the documentation set"}, False), # NL, no AS, stays safe ({"query": "GRANT SELECT ON t TO u"}, True), # privilege grant (multi-word) ({"query": "REVOKE ALL ON t FROM u"}, True), # privilege revoke (multi-word) ({"query": "SELECT * FROM grants"}, False), # 'grants' table stays safe ({"url": "http://x", "method": "DELETE"}, True), # mutating HTTP verb arg ({"method": "POST"}, True), ({"verb": "PUT"}, True), # alternate method-key name ({"method": "GET"}, False), # read HTTP verb stays safe ({"method": "HEAD"}, False), ], ) def test_mcp_mutating_arguments(args, unsafe): name = f"{MCP_TOOL_PREFIX}db__query_database" assert is_potentially_unsafe_tool_call(name, args) is unsafe # ── loop behavior ─────────────────────────────────────────────────── _DEFAULT_TOOLS = [ {"type": "function", "function": {"name": "python"}}, {"type": "function", "function": {"name": "web_search"}}, ] class _FakeExecuteTool: def __init__(self): self.calls = [] self.disable_sandbox_seen = [] def __call__( self, name, arguments, *, cancel_event = None, timeout = None, session_id = None, thread_id = None, rag_scope = None, disable_sandbox = False, ): self.calls.append((name, arguments)) self.disable_sandbox_seen.append(disable_sandbox) return f"RESULT[{name}]" def _tool_call(name, args_json): return f'{{"name": "{name}", "arguments": {args_json}}}' def _multi_turn(turns): turn_iter = iter(turns) def _gen(_messages): try: yield next(turn_iter) except StopIteration: return return _gen def _drive(turns, decisions, **loop_kwargs): """Run the loop, resolving each gated tool_start with the next decision.""" decision_iter = iter(decisions) exec_fn = _FakeExecuteTool() # A per-call session id so a leaked pending approval from another test can # never collide with this run's approval registry entries. session = f"{_SESSION}-{uuid.uuid4().hex}" gen = run_safetensors_tool_loop( single_turn = _multi_turn(turns), messages = [{"role": "user", "content": "hi"}], tools = _DEFAULT_TOOLS, execute_tool = exec_fn, session_id = session, **loop_kwargs, ) events = [] for ev in gen: events.append(ev) if ev["type"] == "tool_start" and ev.get("awaiting_confirmation"): resolve_tool_decision(ev["approval_id"], next(decision_iter), session_id = session) return events, exec_fn def _tool_starts(events): return [e for e in events if e["type"] == "tool_start"] def _diag(events, exec_fn): """A compact dump of what the loop actually did, attached to the loop-driving assertions so a full-suite-only failure on CI (which does not reproduce when the file runs alone) reports the real event stream instead of a bare diff.""" return ( f"calls={exec_fn.calls} sandbox_seen={exec_fn.disable_sandbox_seen} " f"events={[(e.get('type'), e.get('awaiting_confirmation'), e.get('tool_name')) for e in events]}" ) def test_auto_mode_does_not_gate_safe_calls(): events, exec_fn = _drive( [_tool_call("python", '{"code": "print(1)"}'), "final"], [], confirm_tool_calls = True, permission_mode = "auto", ) starts = _tool_starts(events) assert starts and starts[0]["awaiting_confirmation"] is False, _diag(events, exec_fn) assert starts[0]["approval_id"] == "" assert exec_fn.calls == [("python", {"code": "print(1)"})], _diag(events, exec_fn) assert exec_fn.disable_sandbox_seen == [False], _diag( events, exec_fn ) # sandbox stays on in auto def test_auto_mode_gates_unsafe_calls(): events, exec_fn = _drive( [_tool_call("python", '{"code": "import os; os.remove(\\"x\\")"}'), "final"], ["allow"], confirm_tool_calls = True, permission_mode = "auto", ) starts = _tool_starts(events) assert starts and starts[0]["awaiting_confirmation"] is True, _diag(events, exec_fn) assert starts[0]["approval_id"] assert len(exec_fn.calls) == 1, _diag(events, exec_fn) assert exec_fn.disable_sandbox_seen == [False], _diag(events, exec_fn) def test_ask_mode_gates_even_safe_calls(): events, _ = _drive( [_tool_call("python", '{"code": "print(1)"}'), "final"], ["allow"], confirm_tool_calls = True, permission_mode = "ask", ) starts = _tool_starts(events) assert starts and starts[0]["awaiting_confirmation"] is True def test_unset_mode_behaves_as_ask(): events, _ = _drive( [_tool_call("python", '{"code": "print(1)"}'), "final"], ["allow"], confirm_tool_calls = True, ) starts = _tool_starts(events) assert starts and starts[0]["awaiting_confirmation"] is True def test_off_mode_never_gates_and_keeps_sandbox(): # "Off": no prompts even for unsafe calls, but the sandbox stays on. events, exec_fn = _drive( [_tool_call("python", '{"code": "import os; os.remove(\\"x\\")"}'), "final"], [], confirm_tool_calls = True, # off must win over a stray confirm flag permission_mode = "off", ) starts = _tool_starts(events) assert starts and starts[0]["awaiting_confirmation"] is False, _diag(events, exec_fn) assert starts[0]["approval_id"] == "" assert exec_fn.disable_sandbox_seen == [False], _diag(events, exec_fn) def test_full_mode_never_gates_and_drops_sandbox(): events, exec_fn = _drive( [_tool_call("python", '{"code": "import os; os.remove(\\"x\\")"}'), "final"], [], confirm_tool_calls = True, # full must win over the confirm gate permission_mode = "full", ) starts = _tool_starts(events) assert starts and starts[0]["awaiting_confirmation"] is False, _diag(events, exec_fn) assert exec_fn.disable_sandbox_seen == [True], _diag(events, exec_fn) def test_bypass_flag_implies_full_mode(): # Legacy callers that only set bypass_permissions keep the same behavior. events, exec_fn = _drive( [_tool_call("python", '{"code": "print(1)"}'), "final"], [], confirm_tool_calls = True, bypass_permissions = True, ) starts = _tool_starts(events) assert starts and starts[0]["awaiting_confirmation"] is False, _diag(events, exec_fn) assert exec_fn.disable_sandbox_seen == [True], _diag(events, exec_fn) def test_bypass_permissions_folds_to_full_on_request_models(): # A legacy bypass caller that also sends a stale ask/auto mode normalizes to # full, so the route guards (which reject ask/auto) don't 400 the request. for cls in (ChatCompletionRequest, AnthropicMessagesRequest): req = cls( messages = [{"role": "user", "content": "hi"}], bypass_permissions = True, permission_mode = "auto", ) assert req.permission_mode == "full" assert req.bypass_permissions is True def test_unknown_permission_mode_normalizes_to_ask_on_request_models(): # An unrecognized mode from a newer UI/client must degrade to the safest gate # ("ask") at the API boundary instead of a 422, so the forward-compat fallback # the tool loops already apply (unknown -> ask) is reachable. None stays unset; # the four known modes pass through untouched. for cls in (ChatCompletionRequest, AnthropicMessagesRequest): for unknown in ("paranoid", "readonly", "bogus", ""): req = cls( messages = [{"role": "user", "content": "hi"}], permission_mode = unknown, ) assert req.permission_mode == "ask", (cls.__name__, unknown) assert ( cls(messages = [{"role": "user", "content": "hi"}], permission_mode = None).permission_mode is None ) for known in ("ask", "auto", "off", "full"): req = cls( messages = [{"role": "user", "content": "hi"}], permission_mode = known, ) # 'full' folds to bypass but the mode string is preserved. assert req.permission_mode == known, (cls.__name__, known) def test_ask_auto_self_enable_confirm_on_chat_request(): # "Ask" gates every call, so a direct /chat/completions caller that requests # ask but omits the legacy confirm flag self-enables it when Unsloth's own tool # loop is requested. Only the router's loop-entry signals count (enable_tools / # mcp_enabled); enabled_tools alone never starts the loop. for loop in ({"enable_tools": True}, {"mcp_enabled": True}): req = ChatCompletionRequest( messages = [{"role": "user", "content": "hi"}], permission_mode = "ask", **loop, ) assert req.confirm_tool_calls is True # "auto" is NOT folded: it only prompts for a classifier-flagged call, so # leaving confirm unset lets the route apply the safe-only-selection exception # (a safe-only auto request needs no stream) instead of an explicit confirm # forcing stream=true. The mode still drives the loop's per-call gate. for loop in ({"enable_tools": True}, {"mcp_enabled": True}): req = ChatCompletionRequest( messages = [{"role": "user", "content": "hi"}], permission_mode = "auto", **loop, ) assert req.confirm_tool_calls is None # enabled_tools by itself is a passthrough filter, not a loop-entry signal: # a client-tool passthrough that also lists enabled_tools must route verbatim # (confirm stays unset), else the confirm-without-stream guard 400s it. for mode in ("ask", "auto"): req = ChatCompletionRequest( messages = [{"role": "user", "content": "hi"}], permission_mode = mode, enabled_tools = ["terminal"], tools = [{"type": "function", "function": {"name": "f"}}], ) assert req.confirm_tool_calls is None # An explicit confirm_tool_calls=False wins over the ask mode (opts out of the # gate), matching _permission_mode_confirm and the Anthropic pre-switch guard; # the fold only self-enables when the flag is unset, so a caller cannot get a # different answer on the chat path than the Anthropic path for the same body. req = ChatCompletionRequest( messages = [{"role": "user", "content": "hi"}], permission_mode = "ask", enable_tools = True, confirm_tool_calls = False, ) assert req.confirm_tool_calls is False # A plain client-tool passthrough (client-supplied tools that Unsloth does not # execute) must NOT self-enable confirm, or the route rejects the passthrough. req = ChatCompletionRequest( messages = [{"role": "user", "content": "hi"}], permission_mode = "ask", tools = [{"type": "function", "function": {"name": "f"}}], ) assert req.confirm_tool_calls is None # ask/auto without any tool request has nothing to gate; confirm stays unset. req = ChatCompletionRequest( messages = [{"role": "user", "content": "hi"}], permission_mode = "ask", ) assert req.confirm_tool_calls is None # Legacy callers with no permission_mode keep their confirm flag untouched. req = ChatCompletionRequest( messages = [{"role": "user", "content": "hi"}], confirm_tool_calls = False, ) assert req.confirm_tool_calls is False # External-provider requests are not folded (the provider branch rejects # confirm_tool_calls with tools, and permission_mode is a local concept). for extra in ({"provider_id": "p1"}, {"provider_type": "openai"}): req = ChatCompletionRequest( messages = [{"role": "user", "content": "hi"}], permission_mode = "ask", enable_tools = True, **extra, ) assert req.confirm_tool_calls is None def test_permission_mode_confirm_derivation(): # The route derives the effective confirm gate from permission_mode so that a # tool loop forced on by CLI policy (no request-level tool flag) still honors # the documented "unset behaves as ask" default. from routes.inference import _permission_mode_confirm def req(**kw): return ChatCompletionRequest(messages = [{"role": "user", "content": "hi"}], **kw) # An explicit confirm flag always wins (True gates, False opts out). assert _permission_mode_confirm(req(confirm_tool_calls = True, stream = False)) is True assert _permission_mode_confirm(req(confirm_tool_calls = False, permission_mode = "ask")) is False # Explicit ask/auto always engage the gate (a non-streaming one is rejected # by the guard that reads this). assert _permission_mode_confirm(req(permission_mode = "ask", stream = False)) is True assert _permission_mode_confirm(req(permission_mode = "auto", stream = False)) is True # off/full never prompt. assert _permission_mode_confirm(req(permission_mode = "off")) is False assert _permission_mode_confirm(req(permission_mode = "full")) is False # An unset mode defaults to ask, but only realizably on a streaming request; # a non-streaming unset request keeps the legacy run-without-gate behavior. assert _permission_mode_confirm(req(stream = True)) is True assert _permission_mode_confirm(req(stream = False)) is False def test_confirm_gate_needs_stream(): # auto only prompts for a classifier-flagged call, so an auto request that can # only select always-safe tools (web_search / RAG) needs no stream and must not # be rejected by the confirm-without-stream guard. from routes.inference import _confirm_gate_needs_stream def req(**kw): return ChatCompletionRequest(messages = [{"role": "user", "content": "hi"}], **kw) safe = ["web_search", "search_knowledge_base"] # auto + a safe-only selection never prompts -> no stream needed. assert _confirm_gate_needs_stream(req(permission_mode = "auto", enabled_tools = safe)) is False assert ( _confirm_gate_needs_stream(req(permission_mode = "auto", enabled_tools = ["web_search"])) is False ) # render_html can prompt when its canvas reaches the network, so a selection # that includes it needs a stream to deliver that prompt. assert ( _confirm_gate_needs_stream( req(permission_mode = "auto", enabled_tools = ["web_search", "render_html"]) ) is True ) # But a selectable unsafe tool, an unrestricted (omitted) selection, MCP, or an # explicit confirm flag all still require streaming under auto. assert ( _confirm_gate_needs_stream(req(permission_mode = "auto", enabled_tools = ["terminal"])) is True ) assert _confirm_gate_needs_stream(req(permission_mode = "auto", enable_tools = True)) is True assert ( _confirm_gate_needs_stream( req(permission_mode = "auto", enabled_tools = ["web_search"], mcp_enabled = True) ) is True ) assert ( _confirm_gate_needs_stream( req(permission_mode = "auto", enabled_tools = ["web_search"], confirm_tool_calls = True) ) is True ) # An explicit empty selection runs no built-in tool, so nothing can prompt and # no stream is needed (distinct from an omitted list, which means all tools). assert ( _confirm_gate_needs_stream(req(permission_mode = "auto", enable_tools = True, enabled_tools = [])) is False ) # ask prompts for every call, so even a safe-only selection needs streaming. assert _confirm_gate_needs_stream(req(permission_mode = "ask", enabled_tools = safe)) is True # off/full never prompt; unset non-streaming keeps the legacy run-without-gate. assert _confirm_gate_needs_stream(req(permission_mode = "off", enabled_tools = safe)) is False assert _confirm_gate_needs_stream(req(permission_mode = "full", enabled_tools = safe)) is False assert _confirm_gate_needs_stream(req(enabled_tools = safe, stream = False)) is False