Skip to content

Block unsafe PyYAML deserialization in Studio tools - #7176

Closed
Imagineer99 wants to merge 143 commits into
unslothai:mainfrom
Imagineer99:fix/pyyaml-auto-approval
Closed

Block unsafe PyYAML deserialization in Studio tools#7176
Imagineer99 wants to merge 143 commits into
unslothai:mainfrom
Imagineer99:fix/pyyaml-auto-approval

Conversation

@Imagineer99

@Imagineer99 Imagineer99 commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • prevent Studio's Python auto-approval policy from approving unsafe PyYAML deserialization
  • allow direct safe parsing through safe_load, safe_load_all, and load/load_all with a proven safe/base loader
  • fail closed when unsafe-capable loaders, callables, modules, dynamic imports, containers, reflection, or namespace registries make the target opaque
  • model sequential assignments, branches, loops, exception paths, functions, classes, comprehensions, and shadowed names to avoid both bypasses and obvious false positives

Why this is a real issue

Before this PR, _check_code_safety(...) returned None for a harmless proof payload using yaml.load(..., Loader=yaml.Loader), and _python_exec(...) executed its Python-object constructor. The submitted first pass blocked the direct spelling but could still be bypassed through callable aliases, load_all, unsafe loader classes, conditional rebinding, an escaped module, aliased dynamic imports, yaml.__dict__, sys.modules, and globals()/locals().

After the final fix, those forms require approval and are refused by the safe-execution path. Normal safe loaders and benign name shadowing remain allowed.

Validation

  • real A/B execution proof: base and the initial submitted patch printed a harmless PYYAML_EXECUTED marker; the hardened policy rejected the payload before execution
  • focused PyYAML policy matrix: 52 passed on Linux/WSL and 52 passed on native Windows
  • broader sandbox/permission regression matrix: 1,035 passed on Linux/WSL
  • targeted Ruff and git diff --check: pass

Compatibility

This changes only Studio's static approval classification. It does not add or alter a CPU/GPU/hardware path. Existing code using yaml.safe_load, yaml.safe_load_all, or a directly provable Safe/Base loader continues to auto-approve; deliberately dynamic or reflective PyYAML use now requires manual review.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces AST-based safety checks to detect and block unsafe PyYAML deserialization (such as yaml.load with unsafe loaders or yaml.unsafe_load) while permitting safe loaders. It also adds corresponding unit tests. The reviewer identified a potential security bypass where users could alias the load functions themselves (e.g., my_load = yaml.load) to evade detection. To address this, the reviewer suggested updating visit_Assign to track load function aliases and adding test cases to verify that these aliased calls are correctly blocked.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

I am having trouble creating individual review comments. Click here to see my feedback.

studio/backend/core/inference/tools.py (4547-4554)

security-high high

The current implementation of 'visit_Assign' only tracks aliases for safe loaders (e.g., 'Safe = yaml.SafeLoader'). It does not track assignments of the load functions themselves (e.g., 'my_load = yaml.load' or 'my_unsafe_load = yaml.unsafe_load'). This allows users to bypass the safety check by aliasing the load functions before calling them. We should update 'visit_Assign' to also track assignments of 'load' and 'unsafe_load' attributes.

        def visit_Assign(self, node):
            if _pyyaml_loader_is_safe(
                node.value, self.yaml_aliases, self.yaml_safe_loader_aliases
            ):
                for target in node.targets:
                    if isinstance(target, ast.Name):
                        self.yaml_safe_loader_aliases.add(target.id)
            if isinstance(node.value, ast.Attribute) and isinstance(node.value.value, ast.Name):
                if node.value.value.id in self.yaml_aliases:
                    if node.value.attr == "load":
                        for target in node.targets:
                            if isinstance(target, ast.Name):
                                self.yaml_load_aliases.add(target.id)
                    elif node.value.attr == "unsafe_load":
                        for target in node.targets:
                            if isinstance(target, ast.Name):
                                self.yaml_unsafe_load_aliases.add(target.id)
            self.generic_visit(node)

studio/backend/tests/test_sandbox_tools.py (30-57)

medium

Add test cases to verify that aliased 'yaml.load' and 'yaml.unsafe_load' calls are correctly blocked by the safety check.

    @pytest.mark.parametrize(
        "code",
        [
            (
                "import yaml\n"
                "yaml.load("
                "'!!python/object/apply:os.system [\"echo pwned\"]', "
                "Loader=yaml.Loader"
                ")"
            ),
            (
                "import yaml as y\n"
                "y.load("
                "'!!python/object/apply:os.system [\"echo pwned\"]', "
                "Loader=y.Loader"
                ")"
            ),
            (
                "from yaml import load, Loader\n"
                "load('!!python/object/apply:os.system [\"echo pwned\"]', Loader=Loader)"
            ),
            (
                "import yaml\n"
                "yaml.unsafe_load('!!python/object/apply:os.system [\"echo pwned\"]')"
            ),
            "import yaml\nloader = get_loader()\nyaml.load('a: 1', Loader=loader)",
            (
                "import yaml\n"
                "my_load = yaml.load\n"
                "my_load('!!python/object/apply:os.system [\"echo pwned\"]')"
            ),
            (
                "import yaml\n"
                "my_unsafe_load = yaml.unsafe_load\n"
                "my_unsafe_load('!!python/object/apply:os.system [\"echo pwned\"]')"
            ),
        ],
    )

wasimysaid and others added 27 commits July 16, 2026 05:01
* Harden desktop release token permissions

* Specify UTF-8 for workflow permission tests
…menu opt-in (unslothai#7171)

* Studio: make sidebar settings cog clickable, opens settings directly

* Studio: truncate long profile names so the settings cog stays visible

* Studio: tighten spacing between profile name and settings cog

* Studio: render settings cog as a sibling button instead of nesting it in the account trigger

* Studio: cap very long names in the welcome greeting

* Studio: hide the Canvas chat menu item by default behind a settings opt-in

* Studio: keep Canvas in the chat menu settings list as the visibility toggle

* Studio: drop the Canvas row description in chat menu settings

* Studio: keep Canvas visible for profiles that pinned it before the visibility flag
…nslothai#7162)

The permission-levels feature defaults an unset permission_mode to ask on
streaming requests, so the headless smoke probes hang at the approval
prompt until the job timeout. Declare permission_mode full in the tool
probe bodies; the gate itself is covered by unit tests.
…unslothai#7158)

* Make the Inkling reasoning-effort coercion a module-level helper so duck-typed engine stand-ins keep working

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Align Inkling minimal reasoning effort with the reference implementation (0.1)

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
unslothai#7152)

---------

Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
…ror (unslothai#7193)

* fix(save): unsloth_push_to_hub_gguf(save_method="lora") raises NameError

unsloth_push_to_hub_gguf reads is_main_process at save.py:3181 but never
declares it. Its twin unsloth_save_pretrained_gguf declares it (2783) and
uses it the same way (2839) -- the LoRA branch was copied between the twins,
the parameter it depends on was not. There is no module-level global, so the
name resolves as a global load and the branch raises NameError 100% of the
time.

save_pretrained_gguf(save_method="lora", push_to_hub=True) raises a
ValueError that tells users to "use .push_to_hub_gguf(save_method='lora')
instead" -- the documented escape hatch is the broken call.

Add is_main_process to the signature, positioned as in the twin, and forward
it to unsloth_save_pretrained_gguf on the merged path so the parameter is not
silently ignored there. Default stays True, so nothing changes for existing
callers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(save): preserve GGUF push compatibility

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Etherll <61019402+Etherll@users.noreply.github.com>
* refactor(studio): move chat model picker into features/model-picker

Relocate model-selector + its support files from components/assistant-ui
into a self-contained features/model-picker feature (own barrel), mirroring
the modular Hub layout. Pure move + import repoint; no behaviour change.

* feat(model-picker): add per-model config persistence layer

Superset PerModelConfig (customContextLength, kvCacheDtype, speculativeType,
specDraftNMax, tensorParallel, chatTemplateOverride, trustRemoteCode) persisted
to localStorage (unsloth_model_configs) with schema versioning + LRU budget.
KV-dtype and speculative value sets match main's sidebar (no q4_0/ngram-simple).
Reuses features/hub/lib/model-identity for normalization; adds storage-key layer
and applyPerModelConfigToRuntime (sets tensorParallel, which the old PR omitted).

* feat(picker): modular backend for chat-template validate + default fetch

New studio/backend/picker package (schemas/service/routes) mounted at /api/picker:
- POST /api/picker/validate-chat-template (Jinja syntax validation, no false positives)
- GET  /api/picker/chat-template/{model_name} (default template from tokenizer_config.json,
  reusing get_cache_path/resolve_cached_repo_id_case; graceful null, no model-code exec)
Frontend api/templates.ts client + hooks/use-model-defaults lazy cache. No backend
changes to the existing inference load route (per-model load fields already supported).

* feat(model-picker): bind picker on-device list to shared hub inventory

Picker now sources cached + local models from useHubInventory (the Hub's shared
store) via a thin adapter, replacing its own /api/models/* fetchers + module
caches. Hub, download manager, and picker now share one source of truth, so
completed downloads reflect in the picker automatically. Partial/live-download
rows are filtered from the cached lists (unchanged rendering). Local naming/search
preserved via additive LocalInventoryRow modelId/displayName. Variant expander,
scan-folder management, recommended-fit, search, external providers untouched.

Known minor: cached 'Downloaded date' sort tiebreak degrades to alphabetical
(hub cached rows carry no mtime); default 'recent' (load-time) sort preserved.

* feat(model-picker): per-model config step inside the picker

Picking a (non-external) model now opens an in-picker config view built from
main's current load controls (context length, KV cache dtype, speculative
decoding, draft tokens, tensor parallel) plus a chat-template editor backed by
the picker validate/default endpoints. 'Remember for this model' persists the
config per model+variant; Run forwards the config to the existing load flow via
meta.config. External models bypass the step. Two-view orchestration lives in
model-selector (single interception point); pickers.tsx call sites untouched.
trustRemoteCode dropped from PerModelConfig to preserve main's per-load consent.

* feat(chat): apply/persist per-model config through the load flow

handleCheckpointChange threads meta.config into the selection; stageOrLoad and
the autoload/Hub-run paths now apply the picker config (explicit pick or saved
remembered config) via applyPerModelConfigToRuntime before staging/loading, with
keepSpeculative set so a remembered speculative mode survives the model switch.
Replaces the old remembered-load-settings seeding (resolveInitialConfig now the
single source). SelectedModelInput carries config.

* refactor(chat): remove per-model load config from the right sidebar

The load knobs (context, KV cache, speculative, draft tokens, tensor parallel)
and the chat-template editor now live only in the picker config step. The sheet's
Model section keeps the staged Load/Cancel flow (config is applied at pick time);
sampling params, system prompt, and RAG are unchanged. Deletes the superseded
remembered-load-settings module + the store's applyRememberedLoadSettings action,
removes the now-dead sheet state/imports, and points the settings reset at
unsloth_model_configs. Delete-cleanup deferred (stale config is LRU-capped).

* fix(model-picker): remove leftover sidebar-staging cogwheel + empty Model section

The downloaded-variant gear (ModelLoadSettingsAction) staged a model straight
into the right-sidebar Run-settings flow -- the old 'configure before load' path
now fully replaced by the in-picker config step. Removed the gear + its component.
Also gate the sheet's 'Model' section to staged picks only (pendingSelection):
after the load-knob strip its content is staged-only, so it was rendering an
empty section header whenever a model was merely loaded.

* chore(chat): remove dead per-model-config setters + modelControlsDisabled

After the load-config UI moved into the picker, the store's per-model setters
(setKvCacheDtype/setSpeculativeType/setSpecDraftNMax/setTensorParallel/
setCustomContextLength/setChatTemplateOverride) had zero callers
(applyPerModelConfigToRuntime writes via setState), and the sheet's
modelControlsDisabled was unreferenced. Verified dead across the whole tree.

* fix(chat): config-step Load actually loads (ignore Load-on-selection)

Root cause: with Settings > Chat > 'Load on selection' turned OFF, the config
step's load went down the deferred-staging path -- opening the right sidebar with
'<model> is staged, not loaded yet / Choose Load model'. The in-picker config step
IS the deliberate load action, so its Load now loads immediately (or downloads +
auto-loads when not cached) regardless of the toggle. Renamed the button
'Run model' -> 'Load model' to match. Native/dropped picks still honor the toggle.

* refactor(chat,hub): retire 'Load on selection' — config step is the only load flow

The in-picker config step (and the Hub Run button) now fully supersede the old
stage-to-sidebar flow, so the Load-on-selection toggle is removed everywhere:
- chat stageOrLoad: every pick loads immediately, or downloads + auto-loads when
  not cached (the previous default behaviour, now universal).
- hub Run: drops the stage branch; downloaded GGUFs load directly with their saved
  per-model config (no collision with the chat config step — both end at selectModel).
- store: removed loadOnSelection field/setter/key/default; Settings>Chat toggle and
  its settings-reset entry removed.
- staged sidebar section is now a download-progress view (auto-loads on completion).
No manual staging remains; stageModel is used only for background auto-load downloads.

* feat(model-picker): default chat template from GGUF + thread variant through config flow

Read the embedded tokenizer.chat_template from GGUF files (read_gguf_chat_template
in gguf_metadata) and use it as the per-model default. Plumb gguf_variant through
the picker service, /api/picker/chat-template route, frontend templates API, and
use-model-defaults so the right variant's template is fetched.

Also refine the picker config-page/model-selector wiring, drop the dead
ggufNativeContextLength runtime path, and add the per-model-config storage keys to
the settings prefs export.

* feat(model-picker): read safetensors chat template + hide editor where it has no effect

Resolve the default chat template for safetensors models: prefer the modern
chat_template.jinja, fall back to the tokenizer_config.json chat_template field,
then chat_template.json (multimodal processor), then the GGUF embedded template.
Applied to local dirs, the HF cache snapshot scan, and the HF remote fetch.

Hide the chat-template editor in the picker for safetensors models — the override
is only applied at load by the GGUF/llama.cpp backend, so editing it on safetensors
currently has no effect. GGUF keeps the editor. Nothing removed; the dialog stays
for when the safetensors apply path is wired up in a later branch.

* fix(model-picker): set legacy-migration flag only after the write succeeds

Set unsloth_model_configs_migrated only once writeMap confirms the migrated
map persisted, so a quota/storage failure no longer marks migration done and
silently drops the user's pre-existing remembered settings — the next load retries.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* MVP model picker fixes

* MVP picker config fix

* MVP safetensors config

* MVP max seq config

* MVP max seq fix

* Fix static max tokens cap ignoring model context

* Fix picker GGUF scan parity

* fix(studio): harden model picker config loading

Apply remembered per-model configs consistently from picker and Hub loads, keep default configs from overriding standing speculative settings, add config access for direct local GGUF files, and support saving or forgetting active model settings without a reload.

* Fix model picker config flow

* Fix model picker config loads

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Avoid recursive per-model config migration reads

* Apply the displayed context length when loading a GGUF

* Fix template validation, cached template lookup, and failed load rollback

- Validate chat templates with the loopcontrols extension so templates
  that use break or continue tags pass the picker validator, matching the
  inference renderer that already accepts them.
- Read the default chat template from the newest cache snapshot rather than
  an arbitrary iterdir order, so an older cached revision no longer prefills
  a stale template.
- Capture the runtime per-model config before a load and reapply it when the
  load fails, so a failed switch leaves the active model context, KV cache,
  template, and speculative settings as they were.

* Make chat template view only for safetensors models

Custom chat template overrides are applied at inference only for GGUF
models, which pass the template to llama-server. The safetensors backend
renders with the model built-in template and ignores the override, so
editing it would save a value that never loads. For safetensors the
config page now opens the template as a read-only preview with a note
that editing is not available yet. This can become editable once
inference support for custom safetensors templates lands in main.

* Fix model picker config edge cases

- Restore prior runtime config when a load no-ops for the active model
- Cap the picker validator request body via the protected prefixes
- Keep the GGUF context slider max above the loaded context
- Fetch subfolder chat templates for uncached Hub repos
- Show the compare side config when reopening the picker

* Keep saved GGUF context above the fallback ceiling

* Show the model config in the run settings sidebar

* Fix model config sidebar reset and context slider

- Stack the remember toggle and action buttons in the sidebar
- Reset the config to defaults instead of the loaded values
- Fetch the native context so the slider max is not the loaded value

* Fix model picker config and download regressions

- Run picker chat template routes off the event loop
- Depth and root guard local template directory scans
- Restore download manager flow for uncached hub picks
- Apply per model context length on reload
- Import model picker symbols from the feature barrel

* Fix model picker config and cached download sorting

- Restore load settings when a Hub run is rejected mid load
- Reuse one NumericValueInput instead of a duplicate copy
- Fix double decode of the model name in the template route
- Remove the unused reset-to-loaded settings action
- Fix cached model download sorting

* Fix model picker per-model config edge cases

Honor a saved or typed max seq length above the model's native context so
RoPE extended values are no longer clamped and silently overwritten. Allow
typing past native while the slider keeps native as a soft ceiling.

Guard the fetch success paths in use-model-defaults against an aborted
signal, and refetch when the HF token changes.

Hash the chat template content in the sidebar remount key instead of its
length. Enable reset for a GGUF whose native context is unknown, and floor
the context slider max so it can never fall below the min.

* Fix GGUF context auto-fit and gated model config token

Stop forcing a 32768 context when a GGUF native context is unknown so the backend auto-fits to VRAM again, while still honoring an explicit context edit.

Send the HF token as a query param so gated safetensors models resolve their max position embeddings.

Derive model default state during render to drop the set-state-in-effect calls.

* Fix native GGUF context ceiling and guard picker template reads

Restore the native context store field so the sidebar slider keeps the
full ceiling for drag and drop GGUFs. Limit local chat template reads to
the browse allowlist, skip malformed repo ids, and drop unused model
picker exports.

* Fix model picker lint boundaries

* Fix model picker review findings

Chat template editor never seeded its draft. Radix only calls onOpenChange
from internal events, so the seed in the nextOpen branch was dead and a model
with a saved override opened empty. Saving then cleared the override. Drop the
dead branch, treat draft as an untouched sentinel, and reset it on every close.

Uncached Hub picks could auto load a model after the user left the chat. Main
detached the staged pick on route exit and on chat context change. Carry the
context key on the pending pick and skip the load when it no longer matches.

Also clear configTarget when the picker closes, restore the onUpdated ref so
variant rows stop resubscribing on every parent render, skip the LRU write when
the entry is already most recent, import NumericValueInput relatively, and drop
the unused ModelUpdateAction barrel export.

* Preserve GGUF context on active reload

* Fix model picker per-model config regressions

- Stop reloading the already loaded model on re-pick
- Hide infra models from the chat picker
- Detect vision support on cached GGUF repos
- Honor saved maxSeqLength on auto load
- Restore default chat template for local GGUFs
- Warn on save failure and revert config on cancel
- Refetch picker inventory on open
- Persist read only per model config safely

* Fix stale model auto load

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix model picker numeric input sizing and constraints

Size value inputs to their content so long context lengths are not clipped,
restrict them to numeric characters, and stop the speculative decoding label
from truncating in the sidebar.

* Fix picker CI tests and harden chat template resolution for PR unslothai#6647

- tests: point the descender guard at the moved model-selector.tsx path
- tests: exclude the disabled Reload model button from the regenerate locator so .first targets the real Regenerate
- picker/service.py: reject symlinked template/gguf leaves that resolve outside the browse allowlist (HF cache reads unchanged)
- compare mode: resolve each pane's own remembered chat template instead of inheriting the other pane's from the store

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Protect future-schema per-model configs from deletion for PR unslothai#6647

savePerModelConfig already refuses to overwrite a stored config whose schema version is newer than this client understands, but deletePerModelConfig did not. Unchecking Remember on an older client therefore silently destroyed a newer client's saved config. Apply the same guard on delete and surface the blocked case through the existing saveFailed toast.

* Protect future-schema per-model configs from quota eviction for PR unslothai#6647

The save and delete guards already refuse to touch a stored config whose schema version is newer than this client understands, but the quota-eviction path did not, so a full store on an older client could still evict a newer client's config. Skip future-schema entries when evicting and fail the save if the budget cannot be met without them.

* Fix GGUF context persistence, compare context, and rollback settings for PR unslothai#6647

Persist a GGUF context override from the user's intent instead of collapsing it against the loaded context, which reintroduced the context-reset (f483878 reverted the native-baseline fix). model-config-page now collapses the saved value against native, and use-chat-model-runtime and chat-adapter retain the requested context on load so re-saving another setting keeps the override; a null request stays null so a VRAM auto-fit never becomes a stored override.

shared-composer: a compare pane with no explicit GGUF context now loads at native (0) like single-view, not the session maxSeqLength that silently shrank the shown context.

use-chat-model-runtime: restore the previous model's KV cache dtype and chat template on a failed-load rollback so it runs as it was, not with backend defaults.

* Preserve native path token when reloading the active model for PR unslothai#6647

handleReloadActiveModel rebuilt the selection without the store's activeNativePathToken, so reloading a file-picked GGUF after a settings change validated the display label as a repo/path and failed. Thread the active native token through the reload selection so native-loaded models reopen correctly.

* Make picker template validation resilient and accept HF generation tags for PR unslothai#6647

Import Jinja lazily inside validate_chat_template so a backend without the optional jinja2 package (GGUF-only installs) still starts instead of raising ModuleNotFoundError at import time. Register a no-op extension for the Transformers {% generation %} assistant-mask tag so pasting a valid HF chat template validates, matching the renderer, rather than being rejected as an unknown tag.

* Honor remembered compare config and parse processor chat_template.json for PR unslothai#6647

* Fix failed-load rollback context and processor template map fallback for PR unslothai#6647

* Restore speculative decoding config on failed-switch rollback

When a model switch fails after the previous model was unloaded, the
rollback reload restored tensor_parallel, KV cache dtype and the chat
template override, but omitted speculative_type and spec_draft_n_max and
cleared their loaded shadows to null. The previous model therefore came
back running at backend defaults (speculation off) while the UI still
showed it enabled, and the status resync confirmed the off state. Resend
the previous model's speculative settings in the rollback load and keep
the store's active and loaded speculative fields in sync with them.

* Reset max sequence length when a model has no saved config

applyPerModelConfigToRuntime reset every per-model field except
maxSeqLength, which it only wrote when the incoming config had one.
maxSeqLength is the sole field carried on store.params, so selecting a
model with no remembered config left the previous model's value in place
and later loaded the new model at that leaked length. Fall back to the
standing default so an unremembered model loads at its own default.

* Surface a message when a variant update cannot start

startManagedUpdate handled the conflict and error start outcomes but let
busy fall through as if the update began, so the confirm dialog closed
with no job created and the cached variant stayed stale. Show an info
message when the repo is busy with a sibling transfer so the click is
not silently dropped.

* Keep per-model speculative choices out of the global default

A staged load with a per-model or one-off config sets keepSpeculative,
which already skips reading the global speculative preference. The
matching save still ran unconditionally, so the model-specific choice was
written to the global unsloth_chat_speculative_type and a later model with
no saved config started from it instead of Auto. Skip saveSpeculativeType
when keepSpeculative so the per-model choice stays isolated.

* Seed non-active model settings from the app default max length

The Run settings page captured initialMaxSeqLength from the loaded
model's runtime params and fell back to it for a model with no saved
config. Opening settings for a different, unloaded model and clicking
Load then sent the active model's context (for example 64k) instead of
the 4096 default, risking validation failures or OOMs. Seed the default
for non-active models and keep the runtime value only for the active one.

* Prefer sidecar tokenizer chat template over the GGUF copy for variants

_chat_template_from_dir returned the embedded GGUF template first when a
variant was selected, reversing the tokenizer-first precedence of the
no-variant path. A model whose chat_template.jinja or tokenizer_config.json
supersedes a stale embedded template then got the wrong template on
variant selection. Keep tokenizer files first regardless of variant; the
variant only picks which GGUF is the fallback. Adds regression tests for
both the tokenizer-wins and gguf-fallback cases.

* Keep per-model speculative choices load-local in autoload and compare

The interactive load path treats a per-model speculative choice as
load-local and skips writing it to the global default. Autoload and
generalized compare still called saveSpeculativeType unconditionally, so a
remembered off or ngram setting leaked into unsloth_chat_speculative_type
and later models with no saved config inherited it. Persist the global
preference only when the value came from the global settings.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: record the compare pane's loaded context in runtime state so the active model's settings and any reload or save use it, not the previous context

* Studio: notify the user when a Hub autoload can't start because another download for the model is already running, instead of silently dropping it

* Studio: drop the merge's orphaned staged-model store helpers and unused alert imports

The main merge left isPendingGguf and pendingSelectionMatches referencing the
removed PendingModelSelection type, and the alert-dialog/alert imports unused
after the permission-mode dropdown replaced the bypass dialog, so tsc -b failed.

* Studio: cache a null default chat template so the viewer stops re-fetching it

A model with no sidecar or embedded template resolves to a terminal null, but
that result was never cached, so reopening the template viewer re-ran the
backend and Hugging Face lookup every time.

* Studio: detect direct-file GGUFs in run settings so Max Tokens uses their context

A GGUF loaded from a local file or custom folder has no variant label, so the
run-settings panel treated it as non-GGUF and clamped Max Tokens to the session
max_seq_length instead of the loaded GGUF context. Detect it via the reported
GGUF context and the .gguf checkpoint suffix, matching the chat page.

* Studio: prompt to re-select a local model file when its lease expired before reload

A file-picked GGUF is reachable only through a native path token that the
desktop host prunes after a TTL. Reloading reused that token blindly, so a
reload long after the initial load failed with an opaque error. Track the
token's expiry and, when it has passed, ask the user to re-select the file
instead of attempting a doomed reload.

* Fix descender-clipping test to tolerate sidebar layout utilities

The sidebar account-block div carries layout utilities (min-w-0, flex-1)
between 'flex' and 'flex-col', so the descender-clipping guard's regex,
which required 'flex' immediately followed by 'flex-col', no longer matched
and the test failed to locate the account-block div. Generalize the prefix
to allow intervening flex utilities while still capturing the leading-*
class before the collapsible visibility utility and asserting leading-tight,
so the guard against clipped glyph descenders is fully preserved.

* Harden picker chat-template resolution

Enforce the 64 KiB chat-template contract at the validate endpoint's request
model so a direct caller cannot submit a template far larger than the frontend
allows (MaxBodyMiddleware only bounds the whole request body, not this field);
oversized templates now return a clean 422.

Apply sidecar-over-GGUF template precedence globally across cached snapshots
instead of per snapshot. A repo with multiple cached revisions previously
returned the first snapshot's template, so a newer GGUF-only revision could
win over an older revision's maintained chat_template.jinja sidecar, which
contradicted the documented intent that sidecars supersede the embedded copy.

* Guard per-model config against future-schema and lossy migration

Two forward-compatibility gaps in the versioned per-model config store:

- The load/apply path returned and normalized a stored record without checking
  its schema version, so a record written by a newer client was reinterpreted
  under the current schema and applied to a live model load, even though save,
  delete and eviction all refuse to touch future-schema records. Reject
  future-schema records on load too.
- The one-time legacy migration enforced the storage budget without protecting
  the entries it had just migrated and set the completion flag unconditionally.
  When storage was already full of future-schema records (which are unevictable
  by an older client), the migrated entries were the only evictable ones and
  could be dropped while migration was still marked complete. Protect the
  migrated keys during eviction and only mark migration complete when they
  survive, so it retries once space frees up.

* Discard chat-template validation results after the dialog closes

Server-side template validation is async, but closing or cancelling the editor
did not abort it, so a late-arriving valid response still called onSave and
applied a template the user had already dismissed. Track a validation token
that is bumped on close and ignore any validation result whose token is stale.

* Record native lease expiry when loading a picked GGUF from the chip

The pending-native-model chip loaded via stageOrLoad directly, bypassing
loadNativeModelIntent, so activeNativePathExpiresAtMs was never recorded for a
chip-loaded file. A later reload then either skipped the lease-expiry guard
entirely (expiry left null) or compared against a previously loaded file's
stale expiry, so reload could reuse an already-pruned token or wrongly block a
still-valid one. Route the chip through loadNativeModelIntent, which builds the
same selection and records the expiry.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Prefer sidecar template for a directly selected local GGUF file

A direct .gguf file path read its embedded chat template without checking the
parent directory for a maintained sidecar (chat_template.jinja /
tokenizer_config.json), while directory and variant selections already prefer
the sidecar. That let the config editor preview or save a stale embedded
template for the same model depending on how it was selected. Check the parent
directory sidecars first, then fall back to the embedded copy, and cover both
paths with tests.

* Resolve cached chat template per revision, newest first

The earlier change searched every cached snapshot for a sidecar before
considering any snapshot's embedded GGUF template, which let an obsolete sidecar
from an older revision override the newest revision's template. Restore
per-snapshot resolution (newest first): a revision's sidecar still supersedes
its own embedded GGUF copy, but a newer revision is no longer overridden by an
older revision's sidecar.

* Preserve autoload transport conflicts and surface background busy downloads

- When a Hub autoload hits a transport conflict, keep pendingHubAutoLoad bound
  instead of clearing it. Clearing it re-keyed the download surface and its
  cleanup cancelled the conflict the toast tells the user to resolve, so the
  Hub resume affordance was gone the moment it appeared. Return early on
  conflict, mirroring the started branch, so resolving it from the Hub still
  auto-loads on completion.
- The background-download branch handled started and conflict but silently
  dropped a busy outcome, leaving the user with no feedback when a peer variant
  of the same repo was already downloading. Surface the same busy toast the
  autoload path uses.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
* fix(studio): recover stalled Hub downloads over HTTP

* fix(studio): preserve retry generation and progress baseline

* fix(studio): keep XET retry handoff nonterminal

* fix(studio): preserve retry cancellation on claim failure

* fix(studio): make retry failure cancellation atomic

* fix(studio): close skipped retry state gaps

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Stabilize chat-only export gate detection on Windows

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Retrigger CI on a user-authored head

* fix(studio): serialize XET HTTP retry handoff

* List XET to HTTP retries that are briefly released from the repo guard as active downloads for PR unslothai#6858

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Settle no-process active downloads on shutdown so a parked XET retry cannot spawn after cleanup for PR unslothai#6858

* Settle exited-error and no-process downloads on shutdown and persist their cancel markers for PR unslothai#6858

* Keep terminal HTTP failures uncancelled and block companion deletion for released retry peers for PR unslothai#6858

* Trim download lifecycle test coverage

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Etherll <61019402+Etherll@users.noreply.github.com>
---------

Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
…on Python 3.14+) (unslothai#7186)

* Studio: don't apply nest_asyncio on plain CLI starts (breaks asyncio on Python 3.14+)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Skip nest_asyncio on Python 3.14+ so notebook and embedded Studio starts also work

* Tighten the nest_asyncio gate comment

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
…rs_bias (unslothai#7189)

* Propagate fp8 block_size before the early return in get_lora_parameters_bias

get_lora_parameters_bias set the fp8 block_size on W/W_quant only after the
disable_adapters/merged early return, so on the merged or disabled path (merged
inference, DPO reference model) a block-fp8 weight lost its real block_size and
downstream fp8 kernels fell back to [128, 128]. The non-bias sibling
get_lora_parameters already sets block_size before its early return; move the
block so both behave the same.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Guard the fp8 block_size against a missing quant state

A decompressed compressed-tensors layer keeps quant_method == "fp8" while its
weight is back to bf16, so it has no quant state and get_lora_parameters_bias
must still return W_quant None for fast_linear_forward to fall back to a plain
matmul. Only attach block_size when a quant state was actually found.

* Guard the sibling get_lora_parameters fp8 block_size against a missing quant state

Mirror the get_lora_parameters_bias guard so a decompressed compressed-tensors
layer (quant_method fp8, bf16 weight, no quant state) does not raise
AttributeError on the fused-LoRA path. Add a CPU-only regression test.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
* feat(studio): expose opt-in MCP control plane

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Harden Studio MCP tools: byte-safe auth, page clamping, forward export/checkpoint fields

Follow-up hardening on the opt-in MCP control plane. All changes are additive
and backwards compatible.

- BearerTokenMiddleware now compares the Authorization header on raw bytes.
  A non-ASCII bearer value previously reached str-based hmac.compare_digest,
  which raises TypeError and surfaced as a 500 instead of a clean 401. The
  constructor also rejects an empty or whitespace-only token so an empty token
  can never match an empty "Bearer " header.
- MCP tools call the route functions directly, which skips FastAPI Query
  validation. list_training_runs and get_recipe_job_dataset now clamp limit and
  offset to the same bounds the HTTP routes enforce (a negative SQLite LIMIT
  otherwise means "no limit").
- export_gguf forwards hf_token (the backend rejects a Hub upload without it),
  accepts a list of quantization methods, and exposes imatrix / imatrix_path so
  the IQ low-bit quants are reachable.
- load_checkpoint forwards hf_token and approved_remote_code_fingerprint so
  gated checkpoints and the remote-code approval retry work. Its docstring is
  corrected: the export backend coexists with training and inference rather than
  freeing GPU work.
- start_training passes via_api_key=False explicitly instead of relying on the
  unfilled Depends default.

Tests: add coverage for non-ASCII and empty-token auth, the correct-token pass
through, non-http scope pass through, pagination clamping, and the forwarded
export/checkpoint fields.

* Harden Studio MCP: cap /mcp request bodies, reject unusable tokens, fix docs

Follow-up hardening from a full review pass. All changes are additive and
backwards compatible.

- Add "/mcp" to _BODY_PROTECTED_PREFIXES so MaxBodyMiddleware enforces the same
  request-body cap it already applies to every other write endpoint (/api/train,
  /api/export, /api/data-recipe, ...). The MCP endpoint accepts authenticated
  POST tool-call bodies; without this an authenticated client could send an
  unbounded body. The middleware only buffers the request body (not the SSE
  response), so streaming is unaffected, and the 500MB default cap never affects
  a real JSON-RPC tool call (verified live).
- Reject a non-ASCII UNSLOTH_STUDIO_MCP_TOKEN at construction. HTTP header values
  are ASCII, so a non-ASCII token cannot be sent by a standard client and would
  silently lock out the endpoint; fail fast instead.
- MCP.md: document the canonical /mcp/ endpoint and note that /mcp redirects to
  it, so clients that do not follow redirected POSTs still connect.

Tests: add non-ASCII token rejection coverage.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten MCP server comments

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
…unslothai#7194)

* fix(tokenizer): check for tokenizer.model after saving it, not before

`fix_sentencepiece_tokenizer` creates its temporary directory, then returns
early unless that directory already contains a tokenizer.model:

    if not os.path.exists(temporary_location):
        os.makedirs(temporary_location)          # fresh, empty

    if not os.path.isfile(f"{temporary_location}/tokenizer.model"):
        return new_tokenizer                     # always true

    old_tokenizer.save_pretrained(temporary_location)   # writes that file

The file only appears on the line after the check, so the guard is always
true and the body never runs. Nothing else writes that path either --
`convert_to_fast_tokenizer` saves into a per-name subdirectory, not
`{temporary_location}/tokenizer.model`.

Both call sites are in `get_chat_template` and are commented "Must fix the
sentence piece tokenizer since there's no tokenizer.model file!" -- the
guard defeats the exact intent the caller states. The effect is silent: the
caller still gets a working `new_tokenizer`, but the sentencepiece piece
rename is skipped, so the mapped token (e.g. the eos token remapped to
`<|im_end|>`) is missing from tokenizer.model and GGUF/llama.cpp exports
carry the old piece.

`check_if_sentencepiece_model` in save.py does the same probe in the right
order -- makedirs, save_pretrained, then isfile. Match it.

Tests are added under tests/saving/ next to the existing sentencepiece
coverage, and to the two Bucket-A lists in consolidated-tests-ci.yml, since
Repo tests (CPU) --ignores tests/saving and these need protobuf.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Clear stale tokenizer.model before the sentencepiece guard

The guard now runs after old_tokenizer.save_pretrained, but the default
temporary_location is a fixed reusable directory. A fast-only tokenizer writes
no tokenizer.model, so a stale file from an earlier sentencepiece call could
pass the guard and patch the wrong model (e.g. mixing models in one process,
like a long-running server). Remove any existing tokenizer.model first, and add
a regression test.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Empty the reusable sentencepiece scratch directory each call

The final AutoTokenizer.from_pretrained reloads the whole temporary_location, so
removing only a stale tokenizer.model still let other artifacts from a previous
tokenizer (added_tokens.json, chat template, etc.) leak into the reload when the
default reusable directory is used across models in one process. Recreate the
directory instead, and add a regression test for the leaked-artifact case.

* Clear only top-level scratch files, keep subdirectories

Recreating the whole reusable directory deleted the {name} subtree that
convert_to_fast_tokenizer stores a converted tokenizer's source vocab in, so
old_tokenizer.save_pretrained could not copy tokenizer.model and the guard
returned the tokenizer unpatched for those legacy converted tokenizers. Remove
only stale top-level files (all the final reload reads) and leave subdirectories
intact. Add a regression test for the converted-source subdirectory.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Keep the current tokenizer's own source vocab when clearing

On a repeated get_chat_template(map_eos_token=True) call, the returned tokenizer's
vocab_file points back at the top-level tokenizer.model, and the cleanup deleted
that source before old_tokenizer.save_pretrained could re-emit it, so the guard
returned the tokenizer unpatched. Skip removing the old tokenizer's own source
vocab while still clearing stale files from a different tokenizer, and add a
regression test.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Use a per-call temporary directory for the sentencepiece fix

The scratch directory defaulted to a single shared path, so concurrent or repeated
get_chat_template(map_eos_token=True) calls could delete or overwrite each other's
tokenizer.model between save and reload (tripping the piece assertion or reloading
the wrong model), and stale files from an earlier tokenizer could leak into the
reload. Work in a unique per-call subdirectory instead: this isolates every call
without deleting anything the caller owns, and replaces the earlier per-file cleanup.
Tests updated to read the patched model from the reloaded directory and to cover
isolation and source-vocab preservation.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Pass only the applied token mappings into the sentencepiece fix

get_chat_template mirrors token remaps into tokenizer.model via
fix_sentencepiece_tokenizer, but two caller paths passed a mapping that did not
match what they wrote to the fast tokenizer JSON, so once the sentencepiece patch
runs the model and JSON disagree:
- the mapped-token path skipped entries whose target already existed but still
  passed the full mapping, renaming a piece the JSON never changed;
- the EOS-swap path swapped both tokens in the JSON but passed only one direction,
  leaving two stop_word pieces and no old EOS piece.
Pass the applied mapping (and both swap directions) instead. Add regression tests.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten sentencepiece guard comments

* Add SPDX license identifier to sentencepiece guard test

* Reclaim the per-call sentencepiece scratch directory

The per-call tempfile.mkdtemp fixed the shared-directory race but never cleaned
up, so a long-running process leaked one scratch dir per call. The dir cannot be
deleted eagerly for sentencepiece tokenizers because the returned tokenizer's
vocab_file points into it (a later save_pretrained copies the patched
tokenizer.model from there). Reclaim it correctly instead: remove the dir right
away on the fast-only path (the returned tokenizer never references it), and
attach a weakref.finalize so the sentencepiece dir is removed once its tokenizer
is garbage collected. Add regression tests for both.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten the scratch-dir reclaim comment

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
* Stabilize Studio regression tests

Rebuild on current main. Restore the set-membership sidebar account-block matcher
(unslothai#6647, which fixed the same order-sensitive regex, was reverted on main, so the
guard is failing on main again) and keep the watchdog replacement-race fix, whose
blocked-watchdog stub now waits without a timeout so a superseded watchdog stays
alive until cleanup regardless of scheduler load.

* Tighten the blocked-watchdog stub comment

---------

Co-authored-by: Daniel Han <unslothshared@gmail.com>
…nslothai#7195)

* fix(dataprep): skip .jsonl lines that are valid JSON but not objects

`_read_file_by_format` json.loads each line and hands the result to
`_extract_text_from_json`, which assumes a dict:

    for field in self._TEXT_FIELDS:
        if field in data and isinstance(data[field], str):

A JSON line does not have to be an object -- `"context"`, `["text"]` and
`42` are all valid JSON. For those, `field in data` stops being a key
lookup and becomes a substring/membership test, so `data[field]` raises:

    "context"        -> "text" in "context" is True (substring!)
                     -> TypeError: string indices must be integers
    ["text", "foo"]  -> TypeError: list indices must be integers
    42               -> TypeError: argument of type 'int' is not iterable

The TypeError escapes past `except json.JSONDecodeError: continue`, so the
whole load dies on one odd line.

That except clause is also the tell: a *malformed* line is already skipped
gracefully. A *well-formed* line that happens not to be an object should be
too -- it carries no text either way. This makes the two agree.

Reachable from `unsloth-cli.py:253` (`--dataset foo.jsonl` auto-detect) and
`RawTextDataLoader` is exported from `unsloth/__init__.py`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Slim the non-object jsonl regression test and shorten the guard comment

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
…nslothai#7230)

* fix(studio): equal padding in dataset source segmented control

* fix(studio): scope dataset source pill layoutId per component instance

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 180e8994a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread studio/backend/core/inference/tools.py Outdated
Comment on lines +5516 to +5518
isinstance(func, ast.Call)
and func.args
and _is_yaml_string(func.args[0])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block stored stdlib resolver results

Because this resolver guard only fires when the resolver call is the immediate callee, code can store the resolved callable first, e.g. f = pydoc.locate('yaml.unsafe_load'); f(payload) or the same with pkgutil.resolve_name, and _check_code_safety returns None for that pattern. That leaves an unsafe PyYAML deserialization path executable in the sandbox despite the new blocklist, so resolver results need to be tracked as unsafe aliases on assignment as well as direct invocation.

Useful? React with 👍 / 👎.

Comment thread studio/backend/core/inference/tools.py Outdated
Comment on lines +5561 to +5564
isinstance(func, ast.Attribute)
and (
func.attr in _PYYAML_SAFE_LOADER_MUTATORS
or func.attr in {"update", "setdefault", "pop", "clear", "__setitem__"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block reflected SafeLoader registry mutations

This branch only recognizes bound SafeLoader mutator calls, so equivalent reflective or unbound dictionary calls are allowed; for example _check_code_safety returns None for getattr(yaml.SafeLoader, 'add_multi_constructor')(...FullConstructor.construct_python_object_apply) followed by yaml.load(..., Loader=yaml.SafeLoader). That re-enables the Python object/apply constructors on SafeLoader and lets the unsafe payload execute under an apparently safe loader.

Useful? React with 👍 / 👎.

Comment on lines +5212 to +5214
self._merge_pyyaml_scope_states(*exit_states)
for statement in node.finalbody:
self.visit(statement)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve failing try states before finally

The finalbody is visited after merging only normal/handler exits, so a try/finally that raises before a sanitizing reassignment is analyzed as if the reassignment always happened. For example, _check_code_safety returns None for from yaml import load; try: 1/0; load = print; finally: load(payload), but at runtime the finally block calls the original unsafe PyYAML load before the exception propagates.

Useful? React with 👍 / 👎.

Comment thread studio/backend/core/inference/tools.py Outdated
Comment on lines +5540 to +5541
and isinstance(func.args[0], ast.Name)
and func.args[0].id == "importlib"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor dynamic import aliases in getattr checks

This check hard-codes the receiver name to importlib, so aliased import modules and builtins.__import__ lookups with a dynamic attribute name are missed; _check_code_safety returns None for import importlib as il; name='import_module'; getattr(il, name)('yaml').unsafe_load(payload) and likewise for builtins/__import__. Those call paths import PyYAML and invoke the unsafe loader without hitting the new deserialization block.

Useful? React with 👍 / 👎.

Comment on lines +946 to +949
if path and path[-1] in _PYYAML_UNSAFE_LOADERS:
return path[-1]
if isinstance(node, ast.Name) and node.id in unsafe_loader_aliases:
return node.id

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat reflected PyYAML loader lookups as unsafe

Unsafe loader detection only catches direct attribute or name references, so reflective access through the PyYAML submodules is missed; _check_code_safety returns None for getattr(yaml.loader, 'Loader')(payload).get_single_data() and for vars(yaml.loader)['Loader'](...). Those constructors are the same unsafe-capable PyYAML loaders that direct yaml.loader.Loader is trying to block, so submodule reflection needs to fail closed too.

Useful? React with 👍 / 👎.

Comment on lines +928 to +932
if isinstance(node, ast.Name):
if node.id in unsafe_load_aliases:
return "unsafe_load"
if node.id in load_aliases:
return "load"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block namespace lookups of imported PyYAML callables

This branch only treats a from-imported unsafe PyYAML callable as dangerous when the callee is still a bare Name, so a trivial namespace lookup bypasses it; _check_code_safety returns None for from yaml import unsafe_load; globals()['unsafe_load'](payload) (also with locals()/vars()). That invokes the same unsafe deserializer the import alias tracking is meant to block.

Useful? React with 👍 / 👎.

@Imagineer99

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 13e2fb73a7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread studio/backend/core/inference/tools.py Outdated
Comment on lines +951 to +953
return _pyyaml_loader_is_safe(
current,
yaml_aliases,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track SafeLoader registry aliases before approving loads

When a SafeLoader registry is assigned to a local, e.g. constructors = yaml.SafeLoader.yaml_constructors, this helper later peels constructors['!run'] = run down to the name constructors and returns false because only loader-class aliases are recognized. That lets the new PyYAML guard approve a mutated SafeLoader followed by yaml.load(..., Loader=yaml.SafeLoader), while the direct yaml.SafeLoader.yaml_constructors[...] = ... spelling is blocked, so the registry-mutation bypass remains open.

Useful? React with 👍 / 👎.

Comment on lines +5731 to +5734
if (
isinstance(func, ast.Attribute)
and func.attr in _PYYAML_SAFE_LOADER_MUTATORS
and _is_pyyaml_module_expr(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track aliases of module-level SafeLoader mutators

This only recognizes module-level registry mutation when the call is spelled directly as yaml.add_constructor(...). If the mutator is first saved, e.g. add = yaml.add_constructor; add('!run', run, Loader=yaml.SafeLoader), the assignment path does not mark add as dangerous and the later yaml.load(..., Loader=yaml.SafeLoader)/yaml.safe_load(...) is approved even though SafeLoader has been extended with arbitrary constructors.

Useful? React with 👍 / 👎.

Comment thread studio/backend/core/inference/tools.py Outdated
self.yaml_load_aliases: set[str] = set()
self.yaml_unsafe_load_aliases: set[str] = set()
self.yaml_unsafe_loader_aliases: set[str] = set()
self.yaml_safe_loader_aliases = set(_PYYAML_SAFE_LOADERS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't treat unbound loader names as SafeLoader aliases

Seeding SafeLoader, BaseLoader, etc. as proven safe aliases before any PyYAML import makes the assignment visitor classify an ordinary binding to those names as a SafeLoader registry mutation. This rejects harmless code like SafeLoader = object() and even the safe alias spelling SafeLoader = yaml.SafeLoader; yaml.load(..., Loader=SafeLoader), so bare loader names should only become trusted after an actual PyYAML binding proves them.

Useful? React with 👍 / 👎.

Comment thread studio/backend/core/inference/tools.py Outdated
Comment on lines +968 to +972
mapping_mutators = {"update", "setdefault", "pop", "clear", "__setitem__"}
func = call.func
if isinstance(func, ast.Attribute):
if (
func.attr in (_PYYAML_SAFE_LOADER_MUTATORS | mapping_mutators)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block unbound registry mutators like operator.setitem

The registry mutation check only handles method-style mutations (plus dict.__setitem__), so an unbound helper such as operator.setitem(yaml.SafeLoader.yaml_constructors, '!run', run) is not recorded as unsafe. In that scenario the SafeLoader registry is modified before a later yaml.load(..., Loader=yaml.SafeLoader)/yaml.safe_load(...), and the new policy still approves the code despite the arbitrary constructor.

Useful? React with 👍 / 👎.

@Imagineer99

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8ce3d8b40d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1159 to +1166
if isinstance(node, ast.Subscript) and _is_dynamic_namespace(
node.value, dynamic_namespace_aliases
):
key = _subscript_key(node.slice)
if key in unsafe_load_aliases:
return "unsafe_load"
if key in load_aliases:
return "load"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail closed on dynamic PyYAML global lookups

When an unsafe PyYAML callable is imported into the module namespace, this branch only recognizes globals()/locals() lookups whose key is a literal string. For a non-literal key _subscript_key returns None and the call is treated as safe, so code like from yaml import unsafe_load; globals()['unsafe' + '_load'](payload) bypasses _check_code_safety and reaches unsafe deserialization. Dynamic namespace lookups should fail closed whenever unsafe PyYAML aliases are in scope.

Useful? React with 👍 / 👎.

Comment thread studio/backend/core/inference/tools.py Outdated
Comment on lines +837 to +838
if not (isinstance(node, ast.Call) and node.args and _is_yaml_string(node.args[0])):
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail closed on dynamic resolver targets

This helper only treats literal resolver strings as PyYAML targets, so pydoc.locate(name)(payload) or resolve_name('yaml.' + 'unsafe_load')(payload) is considered safe even when the runtime value resolves to yaml.unsafe_load. Because these stdlib resolvers can import and return the unsafe PyYAML callable, non-literal resolver arguments need to be blocked rather than ignored.

Useful? React with 👍 / 👎.

Comment on lines +1103 to +1105
reflected = _reflected_member(func)
if reflected is None:
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail closed on reflected SafeLoader mutators

When the SafeLoader mutator name is computed, _reflected_member returns None and this path returns False, so calls such as getattr(yaml.SafeLoader, 'add_' + 'constructor')('!run', run) are allowed. That mutates SafeLoader before a later yaml.safe_load(...), reintroducing code-executing constructors through an API this policy is trying to block.

Useful? React with 👍 / 👎.

Comment thread studio/backend/core/inference/tools.py Outdated
Comment on lines +5064 to +5065
self.dynamic_import_aliases = {"__import__", "import_module"}
self.dynamic_import_module_aliases = {"builtins", "importlib"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track builtins as a dynamic import source

In the sandboxed script context, __builtins__ exposes __import__, but this tracked module set only includes builtins and importlib. As a result, getattr(__builtins__, '__' + 'import__')('yaml').unsafe_load(payload) is not caught by the dynamic-import fallback and _check_code_safety returns None, letting unsafe PyYAML deserialization execute.

Useful? React with 👍 / 👎.

Comment on lines +5901 to +5904
(
(isinstance(func, ast.Name) and func.id in {"setattr", "delattr"})
or (isinstance(func, ast.Attribute) and func.attr in {"setattr", "delattr"})
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track aliased SafeLoader registry setters

This mutation check only recognizes a direct setattr(...) call (or an attribute literally named .setattr), so aliasing the builtin bypasses it: s = setattr; s(yaml.SafeLoader, 'yaml_constructors', {'!run': run}) is allowed and a later yaml.safe_load('!run') can invoke the injected constructor. The policy needs to track setattr aliases or otherwise fail closed when SafeLoader is passed to an opaque setter.

Useful? React with 👍 / 👎.

@Imagineer99

Copy link
Copy Markdown
Collaborator Author

@codex review

@Imagineer99

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c0cdaeacf0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread studio/backend/core/inference/tools.py Outdated
Comment on lines +5194 to +5196
isinstance(node.value, ast.Attribute)
and node.value.attr in _PYYAML_SAFE_LOADER_MUTATORS
and _pyyaml_loader_is_safe(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject reflected SafeLoader mutator aliases

For assignments, this check only treats direct yaml.SafeLoader.add_constructor attributes as escaped mutators; a reflected lookup such as add = getattr(yaml.SafeLoader, 'add_constructor') or vars(yaml.SafeLoader)['add_constructor'] is neither recorded nor blocked. Calling add('!run', run) then makes yaml.safe_load('!run x') use the registered constructor while _check_code_safety returns OK, so reflected mutator aliases bypass the SafeLoader-mutation protection.

Useful? React with 👍 / 👎.

Comment on lines +5286 to +5287
if isinstance(target, (ast.Tuple, ast.List)):
return set().union(*(target_names(elt) for elt in target.elts))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track destructured SafeLoader registry aliases

When code destructures a SafeLoader registry out of a tuple/list, e.g. constructors, _ = (yaml.SafeLoader.yaml_constructors, None), flattening the targets here means _update_pyyaml_bindings later tests the entire tuple RHS once and never records constructors as a registry alias. The subsequent constructors['!run'] = run; yaml.safe_load('!run x') is accepted, so the new PyYAML guard can be bypassed by mutating SafeLoader through a destructured alias.

Useful? React with 👍 / 👎.

Comment thread studio/backend/core/inference/tools.py Outdated
Comment on lines +848 to +849
(isinstance(node.func, ast.Attribute) and node.func.attr in {"locate", "resolve_name"})
or (isinstance(node.func, ast.Name) and node.func.id in resolver_aliases)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject reflected stdlib YAML resolvers

This resolver predicate only recognizes direct pydoc.locate / pkgutil.resolve_name attributes or from-import aliases, so reflected access such as getattr(pydoc, 'locate')('yaml.unsafe_load')(payload) or vars(pydoc)['locate'](...) is not classified. Those calls still return PyYAML's unsafe callable and _check_code_safety currently returns OK, bypassing the resolver-specific block added for unsafe deserialization.

Useful? React with 👍 / 👎.

Comment thread studio/backend/core/inference/tools.py Outdated
dynamic_namespace_aliases: set[str] = frozenset(),
) -> bool:
"""Whether a bound, reflected, or unbound call mutates SafeLoader state."""
mapping_mutators = {"update", "setdefault", "pop", "clear", "setitem", "__setitem__"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject operator.ior registry mutations

The registry mutator set omits in-place union helpers. operator.ior(yaml.SafeLoader.yaml_constructors, {'!run': run}) mutates the same mapping just like the tested |= form, but because ior / __ior__ are not in mapping_mutators, the mutation is accepted and a later yaml.safe_load('!run x') can dispatch to the injected constructor.

Useful? React with 👍 / 👎.

Comment on lines +756 to +757
if isinstance(node.func, ast.Name) and node.func.id == "getattr" and len(node.args) >= 2:
return _subscript_key(node.args[1]) in {"import_module", "__import__"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject reflected import-call invocations

This helper unwraps direct .__call__ attribute access on known import callables, but not the equivalent reflected form. Code like getattr(__import__, '__call__')('yaml').unsafe_load(payload) resolves the YAML module and executes unsafe_load while _check_code_safety returns OK, so the dynamic-import guard needs to treat reflected __call__ on import callables the same as __import__.__call__.

Useful? React with 👍 / 👎.

Comment on lines +5607 to +5609
finally:
self.function_parameter_aliases = parameter_alias_state
self._restore_pyyaml_scope_state(state)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve global PyYAML aliases from function bodies

Restoring the whole PyYAML alias state after visiting a function drops assignments that are explicitly declared global or nonlocal. For example, def enable(): global load; from yaml import unsafe_load as load followed by enable(); load(payload) actually installs the unsafe loader in the outer scope, but _check_code_safety returns OK because the import alias is discarded here.

Useful? React with 👍 / 👎.

Comment on lines +5177 to +5180
elif node.module in {"importlib", "builtins"}:
for alias in node.names:
if alias.name in {"import_module", "__import__"}:
self.dynamic_import_aliases.add(alias.asname or alias.name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track builtins setattr aliases for registry writes

This import handling tracks builtins import helpers, but not setattr / delattr aliases. With from builtins import setattr as s, s(yaml.SafeLoader, 'yaml_constructors', {'!run': run}) mutates the SafeLoader registry and a later yaml.safe_load('!run x') is accepted, whereas the equivalent s = setattr assignment is blocked.

Useful? React with 👍 / 👎.

Comment on lines +5955 to +5957
(isinstance(func, ast.Name) and func.id in self.pyyaml_registry_setter_aliases)
or (isinstance(func, ast.Attribute) and func.attr in {"setattr", "delattr"})
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject type.setattr SafeLoader mutations

The registry setter check only recognizes calls named setattr / delattr, so descriptor forms such as type.__setattr__(yaml.SafeLoader, 'yaml_constructors', {'!run': run}) are missed. That call directly replaces the SafeLoader registry and _check_code_safety still returns OK before yaml.safe_load('!run x') dispatches to the injected constructor.

Useful? React with 👍 / 👎.

Comment on lines +777 to +782
if (
isinstance(node, ast.Attribute)
and node.attr in {"get", "pop", "setdefault", "__getitem__"}
and _is_dynamic_namespace(node.value, dynamic_namespace_aliases)
):
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat builtins dict lookups as dynamic imports

The PyYAML dynamic-namespace helper does not recognize __builtins__.__dict__ / builtins.__dict__, even though the broader auto-safety code treats arbitrary __dict__ lookups as dynamic. As a result, __builtins__.__dict__['__import__']('yaml').unsafe_load(payload) imports YAML and invokes the unsafe loader while _check_code_safety returns OK.

Useful? React with 👍 / 👎.

@Imagineer99

Copy link
Copy Markdown
Collaborator Author

@codex review

@Imagineer99

Copy link
Copy Markdown
Collaborator Author

@codex review

1 similar comment
@Imagineer99

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 934dc01b69

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +5254 to +5255
if _pyyaml_safe_loader_mutator_reference(
node.value,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject SafeLoader mutators hidden in containers

When the SafeLoader mutator is wrapped before assignment, e.g. add = [yaml.SafeLoader.add_multi_constructor][0], this check only examines the top-level RHS and the generic visit does not record the contained mutator. A user can then call add(...FullConstructor.construct_python_object_apply) and yaml.safe_load an !!python/object/apply payload without _check_code_safety returning an error, so the new PyYAML block can be bypassed by one list/dict indirection.

Useful? React with 👍 / 👎.

Comment on lines +6027 to +6028
and not (isinstance(func, ast.Name) and func.id in self.function_parameter_aliases)
and (not node.args or _subscript_key(node.args[0]) is None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject parameterized imports before reflective lookup

Because this condition skips function parameters, a helper like def parse(im, name): return getattr(im(name), "unsafe_load")(payload) is not reported when later called as parse(__import__, "yaml"): the module name is nonliteral so _is_pyyaml_module_expr cannot identify the returned module, and the reflective getattr path never hits the direct .unsafe_load parameter check. This leaves an unsafe PyYAML load executable under the sandbox.

Useful? React with 👍 / 👎.

Comment thread studio/backend/core/inference/tools.py Outdated
return None

visitor = DeclarationVisitor()
for statement in getattr(node, "body", ()):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Special-case lambda bodies before iterating declarations

For code that contains any lambda, visit_Lambda now calls _function_scope_declarations, but Lambda.body is a single expression rather than an iterable statement list, so this loop raises TypeError before execution. That means benign snippets like f = lambda x: x fail the sandbox checker instead of running normally.

Useful? React with 👍 / 👎.

Comment on lines +5410 to +5413
resolver_target = (
_pyyaml_resolver_target(value, self.pyyaml_resolver_aliases)
if value is not None
else None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track aliases to stdlib resolver callables

When pydoc.locate or pkgutil.resolve_name is first assigned to a local, e.g. loc = pydoc.locate; loc('yaml.unsafe_load')(payload), this binding logic only records names assigned the result of a resolver call and never records the resolver callable itself. The later call is therefore treated as an ordinary function call, allowing PyYAML unsafe_load to be invoked by the sandbox.

Useful? React with 👍 / 👎.

Comment on lines +964 to +967
reflected = _reflected_member(node)
if reflected is not None:
base, member = reflected
return member in _PYYAML_SAFE_LOADER_REGISTRIES and _pyyaml_loader_is_safe(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail closed on dynamic SafeLoader registry lookups

When the registry name is computed, e.g. getattr(yaml.SafeLoader, name)['!run'] = run with name = 'yaml_multi_constructors', _reflected_member returns None and this check falls through instead of treating the target as a possible SafeLoader registry. That lets code install a constructor and then call yaml.safe_load(...) without _check_code_safety reporting the mutation.

Useful? React with 👍 / 👎.

Comment thread studio/backend/core/inference/tools.py Outdated
finally:
self.loop_depth -= 1
body_state = self._pyyaml_scope_state()
self._merge_pyyaml_scope_states(base_state, body_state)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revisit loop bodies after alias state changes

The loop visitor analyzes the body once using only the pre-loop aliases and merges aliases afterward, so loop-carried registry aliases are missed: constructors = {}; for _ in range(2): constructors['...'] = ...; constructors = yaml.SafeLoader.yaml_multi_constructors mutates SafeLoader on the second iteration, yet the later yaml.safe_load is allowed. Loop bodies need a fixpoint or fail-closed handling for aliases created inside the loop.

Useful? React with 👍 / 👎.

Comment thread studio/backend/core/inference/tools.py Outdated
_AUTO_UNSAFE_PY_LOAD_MODULES = frozenset({"torch", "joblib", "cloudpickle"})
# PyYAML's safe/base loaders do not construct arbitrary Python objects. Treat
# yaml.load with any other loader (or a dynamic/missing loader) as unsafe.
_PYYAML_SAFE_LOADERS = frozenset({"BaseLoader", "CBaseLoader", "SafeLoader", "CSafeLoader"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block SafeConstructor registry mutations

SafeLoader inherits constructor registries from yaml.constructor.SafeConstructor, but the protected loader set only covers the loader classes themselves. Code can do yaml.constructor.SafeConstructor.add_constructor('!run', run) or mutate SafeConstructor.yaml_constructors, then yaml.safe_load('!run x') executes the registered constructor while _check_code_safety returns no error.

Useful? React with 👍 / 👎.

Comment thread studio/backend/core/inference/tools.py Outdated
return (
isinstance(node, ast.Constant)
and isinstance(node.value, str)
and node.value.split(".", 1)[0] == "yaml"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recognize pkgutil's colon resolver syntax

pkgutil.resolve_name also accepts package:object names. Because this helper only treats strings whose dot-prefix is yaml as PyYAML targets, resolve_name('yaml:unsafe_load')(payload) is allowed even though it resolves to yaml.unsafe_load at runtime.

Useful? React with 👍 / 👎.

Comment on lines +5401 to +5403
dynamic_import_alias = value is not None and _is_dynamic_import_callable(
value, self.dynamic_import_aliases, self.dynamic_namespace_aliases
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track partial-wrapped import callables

When __import__ or import_module is wrapped with functools.partial, e.g. im = partial(__import__, 'yaml'); im().unsafe_load(payload), this assignment does not mark im as a dynamic import alias because it only checks whether the outer partial(...) call itself is an import callable. The later zero-argument call is not recognized as a PyYAML module, so the unsafe load runs.

Useful? React with 👍 / 👎.

@Imagineer99

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 48171b00cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1188 to +1191
if (
isinstance(func, ast.Call)
and isinstance(func.func, ast.Name)
and func.func.id == "getattr"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block partial-wrapped SafeLoader mutators

When a SafeLoader mutator is invoked through functools.partial, call.func is an ast.Call, but this new branch only handles getattr(...) calls and then falls through to False. A sandboxed snippet like partial(yaml.SafeLoader.add_constructor, '!x', run)(); yaml.safe_load('!x v') is therefore approved while still mutating the global SafeLoader registry, so the PyYAML deserialization gate can be bypassed without confirmation.

Useful? React with 👍 / 👎.

Comment on lines +761 to +764
if isinstance(node, ast.Subscript):
return _is_dynamic_namespace(node.value, dynamic_namespace_aliases) and _subscript_key(
node.slice
) in {"import_module", "__import__"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Unwrap literal-container dynamic imports

If the import callable is moved through a literal container and immediately indexed, this subscript branch returns False because it only accepts dynamic namespaces. For example, [__import__][0]('yaml').unsafe_load(payload) and [import_module][0]('yaml').unsafe_load(payload) pass _check_code_safety even though they reach the same unsafe PyYAML loader path as direct dynamic imports, so the new deserialization block can be bypassed.

Useful? React with 👍 / 👎.

Comment thread studio/backend/core/inference/tools.py Outdated
Comment on lines +880 to +884
and (
(isinstance(node.func, ast.Attribute) and node.func.attr in {"locate", "resolve_name"})
or (isinstance(node.func, ast.Name) and node.func.id in resolver_aliases)
or (reflected is not None and reflected[1] in {"locate", "resolve_name"})
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Unwrap containerized stdlib resolvers

This resolver check only recognizes direct pydoc.locate / pkgutil.resolve_name references, so putting the resolver in a literal container bypasses it. For example, [pydoc.locate][0]('yaml.unsafe_load')(payload) and [pkgutil.resolve_name][0]('yaml:unsafe_load')(payload) are currently approved while resolving and calling the unsafe PyYAML loader.

Useful? React with 👍 / 👎.

Comment on lines +1205 to +1209
if isinstance(func, ast.Attribute):
if func.attr in (
_PYYAML_SAFE_LOADER_MUTATORS | mapping_mutators
) and _pyyaml_safe_loader_registry_mutation(
func.value,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Detect super-bound SafeLoader mutators

When the mutator is reached through super(yaml.SafeLoader, yaml.SafeLoader), func.value is a super(...) call, so this registry-mutation check does not recognize that add_constructor is still bound to yaml.SafeLoader. A snippet such as super(yaml.SafeLoader, yaml.SafeLoader).add_constructor('!x', run); yaml.safe_load('!x v') passes the sandbox check while modifying the global SafeLoader constructor table.

Useful? React with 👍 / 👎.

@Imagineer99

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8bf31dedad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +940 to +943
isinstance(lookup_func, ast.Attribute)
and lookup_func.attr in {"get", "pop", "setdefault", "__getitem__"}
and _is_dynamic_namespace(lookup_func.value, dynamic_namespace_aliases)
):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recognize unbound namespace lookups

Because this only accepts bound namespace methods, equivalent unbound lookups are not classified as PyYAML module expressions. In sandboxed Python, import yaml; dict.__getitem__(globals(), 'yaml').unsafe_load(payload) or operator.getitem(globals(), 'yaml') returns the same module, but _check_code_safety currently returns None, so auto mode can execute the unsafe loader this patch is meant to block. Please treat unbound dict/operator lookups with a dynamic namespace first argument the same way as bound lookups.

Useful? React with 👍 / 👎.

Comment on lines +1303 to +1306
elif (
isinstance(func, ast.Name)
and func.id in mapping_mutators
and call.args

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track aliases to mapping mutators

This branch only recognizes registry mutators when the callee name is literally setitem/__setitem__, so aliases or container-selected operator.setitem are missed. For example s = operator.setitem; s(yaml.SafeLoader.yaml_constructors, '!run', run); yaml.safe_load('!run x') passes _check_code_safety even though it mutates SafeLoader before safe_load, bypassing the new SafeLoader-registry guard. Track aliases/literal containers for these mutators as with the other PyYAML callables.

Useful? React with 👍 / 👎.

Comment on lines +864 to +866
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "getattr"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track aliases to getattr for reflection

This helper only recognizes reflection when the callee is the literal builtin name getattr. In sandboxed Python, g = getattr; g(importlib, 'import_module')('yaml').unsafe_load(payload) and g(yaml.SafeLoader, 'add_constructor')('!x', run); yaml.safe_load('!x') currently return None from _check_code_safety, so aliasing the builtin bypasses the new dynamic-import and SafeLoader-mutator protections. Track aliases to getattr anywhere _reflected_member is used.

Useful? React with 👍 / 👎.

Comment on lines +817 to +823
if (
isinstance(node, ast.Attribute)
and node.attr == "__dict__"
and _is_dynamic_namespace(node.value, dynamic_namespace_aliases)
):
return True
if isinstance(node, ast.Attribute) and node.attr == "modules":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat importlib module dict lookups as imports

Because __dict__ is considered a dynamic namespace only when its base is already a dynamic namespace, the imported importlib module's dictionary is ignored. import importlib; importlib.__dict__['import_module']('yaml').unsafe_load(payload) reaches PyYAML's unsafe loader while _check_code_safety returns None, bypassing the dynamic-import guard without using globals() or sys.modules. Include dynamic_import_module_aliases such as importlib when recognizing __dict__ namespace lookups.

Useful? React with 👍 / 👎.

Comment on lines +5515 to +5519
isinstance(value, ast.Attribute)
and value.attr in {"setattr", "delattr"}
and isinstance(value.value, ast.Name)
and value.value.id in self.dynamic_import_module_aliases
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve aliases to class-level setters

This alias propagation recognizes setattr/delattr and builtins.setattr, but not equivalent class-level setters that the direct call path later blocks. In sandboxed Python, s = type.__setattr__; s(yaml.SafeLoader, 'yaml_constructors', dict(yaml.SafeLoader.yaml_constructors, **{'!run': run})); yaml.safe_load('!run x') is accepted and lets a custom SafeLoader constructor run, so assigning the setter to a name bypasses the SafeLoader-registry mutation guard. Track aliases for type.__setattr__/__delattr__ as registry setters too.

Useful? React with 👍 / 👎.

Comment on lines +1374 to +1377
if isinstance(node, ast.Subscript) and _is_dynamic_namespace(
node.value, dynamic_namespace_aliases
):
key = _subscript_key(node.slice)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recognize namespace get for loader aliases

This only treats subscripted dynamic namespaces as references to imported PyYAML callables, so the equivalent globals().get/locals().get path is missed. In sandboxed Python, from yaml import unsafe_load as u; globals().get('u')(payload) returns the unsafe loader while _check_code_safety returns None, bypassing the alias protections added here. Handle bound namespace methods such as get, pop, and setdefault for load_aliases and unsafe_load_aliases as well.

Useful? React with 👍 / 👎.

Comment on lines +871 to +875
if isinstance(node, ast.Subscript):
member = _subscript_key(node.slice)
if member is None:
return None
namespace = node.value

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle reflected dict get lookups

_reflected_member handles vars(obj)['member'] and obj.__dict__['member'], but not the same lookup through .get. That leaves bypasses such as pydoc.__dict__.get('locate')('yaml.unsafe_load')(payload) and vars(yaml.constructor.SafeConstructor).get('yaml_constructors')['!run'] = run; yaml.safe_load('!run x'), both of which currently return None from _check_code_safety. Treat mapping .get/.pop on reflected dictionaries like subscripts so resolver and registry lookups cannot escape inspection.

Useful? React with 👍 / 👎.

Comment on lines +6188 to +6192
if (
_is_dynamic_import_callable(
func,
self.dynamic_import_aliases,
self.dynamic_namespace_aliases,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Detect higher-order dynamic imports

The dynamic-import guard only recognizes the import callable when it is invoked directly, so higher-order helpers can materialize the PyYAML module without being classified. For example next(map(__import__, ['yaml'])).unsafe_load(payload) and list(map(__import__, ['yaml']))[0].unsafe_load(payload) both pass _check_code_safety while reaching the unsafe loader. Treat higher-order invokers that call __import__/import_module with a YAML literal as opaque unsafe PyYAML module escapes.

Useful? React with 👍 / 👎.

Comment on lines +6422 to +6426
if _pyyaml_load_call_is_unsafe(
node,
self.yaml_aliases,
self.yaml_load_aliases,
self.yaml_unsafe_load_aliases,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject dynamic code before PyYAML scanning

This check only inspects statically visible call nodes, so dynamic execution can build the exact unsafe PyYAML call after the policy has approved the source. In the sandboxed execution path, exec("import yaml\nyaml.unsafe_load(payload)") and import yaml; eval('yaml.unsafe_load')(payload) both make _check_code_safety return None, even though the direct spelling is now rejected. Block exec/eval/compile in the execution-time safety check or recursively analyze literal code strings before allowing them.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.