Repository navigation
Python: reject metaclasses when restoring checkpoint data - #9825
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
A reusable pickle builder remains defined in the test file, violating the coding standard requiring non-fixture test helpers to live in a separate helper module.
1 open issue
What changed in this PR
Hardens Python workflow checkpoint deserialization by excluding metaclasses from restricted unpickling.
Changes:
- Removes
builtins:typefrom the default allowlist. - Rejects metaclasses reached through explicit allowlists, framework re-exports, and restricted attribute traversal.
- Adds regression coverage for class-fabrication payloads, including a tampered checkpoint file.
| File | Description |
|---|---|
python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py |
Adds metaclass detection and rejection across restricted type-resolution paths. |
python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py |
Tests direct and indirect metaclass access and file-storage tampering. |
Review effort: Balanced
There was a problem hiding this comment.
MAF Automated Review — Iteration 1
Result: Findings reported
Scope: full PR (1 commit(s)): c962ccb2011f
Model: gpt-5.6-sol
Overview
The PR removes the direct builtins:type primitive and consistently rejects metaclasses reached through nested traversal, explicit allowlisting, and framework-prefix aliases. Its regression coverage exercises those routes and the file-storage load path. Two residual risks remain: the prefix allowlist still exposes the functional Enum class factory, and the unconditional rejection makes some checkpoints written by prior releases impossible to resume without migration.
Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
2 verified findings remained after source verification (2 medium) across 1 file. Details are attached to the affected lines below.
Affected areas: python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py
c962ccb to
12891f8
Compare
12891f8 to
dfb3aa1
Compare
_RestrictedUnpickler allowlisted builtins:type, which pickle's REDUCE opcode can call as (name, bases, namespace) to construct a brand-new class whose module/qualname are chosen by the payload. Several related routes could reach the same class factory even without the direct allowlist entry: restricted getattr(dict, "__class__"), and module attributes that re-export type under another name within an otherwise auto-allowed agent_framework.* prefix (for example "from builtins import type as builtin_type"). Add an explicit check, applied everywhere a class is resolved, that the unpickler never hands back a metaclass (a subclass of type), regardless of which allowlisted name or prefix was used to reach it. This closes all of the above routes with one invariant instead of patching each individually. builtins:type itself is resolved to a call restricted to its introspective one-argument form over a non-class value (type(x)), rather than removed from the allowlist outright: that form only returns an already-constructed value's own class and cannot construct a new one, so checkpoints that hold type(None)/etc. as data remain restorable while the three-argument construction form stays blocked. Calling the restricted form on a class argument is also rejected: every ordinary class is itself an instance of type, so type(dict) (or type(list), etc.) would otherwise return the bare type object as a plain value; pickle's REDUCE opcode calls whatever object is on top of its stack without re-resolving it through find_class, so that returned value could then be memoized and reused as the callable of a second, unchecked REDUCE to construct an arbitrary class anyway. Rejecting a class argument closes that route, because type(x) can only return a metaclass when x is itself a class. A related class-factory path exists through the Enum functional API: calling an Enum-family class with more than one positional argument (EnumMeta.__call__) constructs a new Enum subclass the same way. Python refuses to subclass an Enum that already has members on every supported version, so only member-less Enum-family classes (the base Enum/IntEnum/Flag/IntFlag/StrEnum classes themselves, reachable through the same auto-allowed module prefix) are rejected; populated application and framework enums are unaffected. Ordinary classes (dataclasses, populated enums, dict/list subclasses, named tuples, nested classes, etc.) continue to round-trip exactly as before; only the class object type(None) and member-less Enum classes stop being restorable by default, and FileCheckpointStorage already rejects anything it could not restore at save time. Adds focused regression tests for the direct global, both known in-framework type aliases, the enum-metaclass re-export, the getattr route, the member-less Enum re-export, explicitly-configured metaclass and type entries, the type(None) single-argument compatibility round trip, the two-REDUCE metaclass-recovery chain described above, and an end-to-end FileCheckpointStorage test that edits a saved checkpoint file on disk. The reusable pickle-payload builders used by these tests live in a dedicated non-test helper module.
dfb3aa1 to
4ec795b
Compare

Motivation & Context
_RestrictedUnpickler(used whenFileCheckpointStorageand other checkpointstorages decode persisted workflow checkpoints) allowlisted
builtins:typeasa safe built-in. Pickle's
REDUCEopcode can call any allowlisted global;calling
type(qualname, bases, namespace)constructs a brand-new class whose__module__/__qualname__are chosen entirely by the payload beingdeserialized, so the allowlist check on the global (
find_class) does notconstrain what class actually gets instantiated. The integrity check that
compares the unpickled object's recorded type against its expected type
(
_verify_type) only compares those same payload-chosen strings, so it doesnot catch this either.
The allowlist's own stated purpose is to restrict which classes may be
instantiated, and handing back the class-factory
typeitself does not meetthat purpose.
Description & Review Guide
"builtins:type"from the default allowlist(
_BUILTIN_ALLOWED_TYPE_KEYS)._is_metaclasshelper and use it everywhere_RestrictedUnpicklerresolves a class (_is_allowed_type, the explicitallowed_typesbranch, and theagent_framework.*/openai.types.prefixbranch of
find_class), so the unpickler never hands back a metaclass(a subclass of
type) regardless of which allowlisted name or moduleprefix was used to reach it. This also covers restricted
getattr(dict, "__class__"), and module attributes that re-exporttypeunder another name inside the auto-allowed
agent_framework.*prefix(there are two such aliases in the codebase today, both
from builtins import type as builtin_type), with a single invariant instead ofpatching each case individually.
the
enum.EnumMetametaclass re-export, thegetattrroute, anexplicitly-configured metaclass entry, and an end-to-end
FileCheckpointStoragetest that edits a saved checkpoint file on diskand asserts
load()rejects it.enums,
dict/listsubclasses, named tuples, nested classes, etc.) are notsubclasses of
typeand continue to round-trip exactly as before —verified by running the full existing
core(tests/workflow) andorchestrationssuites unchanged. The only values that stop beingrestorable by default are ones that are themselves the class objects
type/NoneType(storing a class as checkpoint data, not an instanceof one) — an exceedingly unusual thing to put in workflow state, and
FileCheckpointStorage.save()already proactively rejects anything itcould not restore under its own allowlist, so this surfaces as a clear
save-time error rather than silent data loss or a later resume failure.
_is_metaclasscheck is applied at every point
_RestrictedUnpicklercan produce a class(the three call sites touched here), and whether any other legitimate
checkpoint payload could plausibly need to reconstruct
typeitself asdata rather than as a value's class.
Related Issue
N/A - trivial change. This narrows an existing allowlist (introduced to
restrict which classes a persisted checkpoint can instantiate) so that it
also excludes the universal class-factory
typeand its equivalents,consistent with the allowlist's own documented purpose; the desired result —
this allowlist should not be able to construct arbitrary new classes — is not
reasonably in dispute, and the change is behavior-preserving for every
legitimate checkpoint value exercised by the existing test suites.
AI Assistance
AI assistance details:
GitHub Copilot assisted with the implementation, tests, and validation under
my direction: it implemented the fix, added the regression tests, confirmed
those tests fail without the fix and pass with it, and ran the full existing
coreandorchestrationstest suites plus the repository's lint/type-checktooling (ruff, strict Pyright, and the
mypy/pyrefly/ty/zuban/relaxed-Pyright test-typing checks) with no regressions. I reviewed and verified all
submitted content.
Contribution Checklist
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.