Skip to content

security: restricted unpickler for sandbox-to-host return values#137

Merged
Meirtz merged 15 commits into
masterfrom
security/restricted-unpickler
Jul 3, 2026
Merged

security: restricted unpickler for sandbox-to-host return values#137
Meirtz merged 15 commits into
masterfrom
security/restricted-unpickler

Conversation

@Meirtz

@Meirtz Meirtz commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Return values from client.remote(fn, ...) travel as pickle bytes and are decoded on the host. Plain pickle.loads reconstructs objects by invoking whatever callables the stream names, so decoding a value influenced by sandbox-side code (a cloned repo, a generated patch, a benchmark task) is a trust-boundary concern. This PR keeps pickle as the wire format but decodes sandbox return values through a strict allowlist.

  • agentix/runtime/shared/safepickle.py adds restricted_loads, wired into the host-side decode on both the Socket.IO and HTTP result paths (RuntimeClient._unpickle_value).
  • find_class admits only exact, individually reviewed globals: stdlib value types (datetime, decimal, fractions, uuid, collections, pathlib), builtin containers and exceptions, numpy ndarray/dtype, inert reconstruction helpers (copyreg, numpy _reconstruct), and six first-party return types registered by exact module and name — TunnelHandle, BashResult, UploadResult, ClaudeCodeResult, the qwen_code Result, and PrepareEnvResult. Everything else is refused without importing the named module.
  • Pickle extension opcodes (EXT1/EXT2/EXT4) are rejected before decoding: the copyreg extension registry resolves names outside find_class, so it cannot be validated against an exact allowlist.
  • Refusals raise agentix.RestrictedUnpickleError (exported). Extension point: safepickle.allow_type(Class) trusts one exact class; AGENTIX_PICKLE_TRUST=1 restores plain decode for deployments where the whole sandbox is trusted.
  • The sandbox-side decode of host-sent arguments/context is unchanged — that is the trusted host→sandbox direction.
  • agentix/runtime/PROTOCOL.md documents the policy.

Compatibility

Returning builtins, stdlib value types, numpy arrays, and all framework result types works unchanged. A workload returning its own classes now needs a host-side allow_type(Class) opt-in (or the full-trust env var); the refusal message says exactly this.

Testing

  • pytest -q tests plugins: 576 passed, 6 deselected
  • Focused: tests/runtime/test_safepickle.py, tests/runtime/test_protocol.py (end-to-end refusal), tests/test_public_exports.py — 98 passed
  • ruff check . clean; pyright 0 errors at --pythonversion 3.11 and 3.13

Closes #116. Stage E of the #122 split (stages A–D: #133, #134, #135, #136; stage F deferred).

🤖 Generated with Claude Code

Meirtz and others added 15 commits July 3, 2026 11:27
…116, PR #122 stage E)

Return values travel host-ward as pickle.dumps(result) and the host
decoded them with plain pickle.loads. pickle reconstructs objects by
invoking whatever callables a stream names, so decoding a value shaped
by a less-trusted sandbox workload (a cloned repo, a generated patch, a
benchmark task) is a trust boundary: the returned object can direct
reconstruction on the host (#116).

Keep pickle as the return codec (the msgpack-returns alternative was
evaluated and closed as #118) and decode host-side through a strict
allowlist (agentix/runtime/shared/safepickle.py). find_class permits
only:
  - a reviewed set of value types whose construction has no external
    effect (stdlib data: datetime/decimal/fractions/uuid/collections/
    pathlib; builtin containers + exceptions; numpy arrays), and
  - a small set of inert reconstruction helpers (copyreg, numpy
    _reconstruct),
and refuses everything else WITHOUT importing it. A denylist was
rejected as unsound: a stream can name a C-accelerator module
(_operator vs operator), a callable produced by one reconstruction step
is invoked by the next without passing find_class (so attribute-access
helpers must never be admitted), and many ordinary constructors have
side effects. A closed allowlist of value types closes all three.

First-party types (agentix.*) are trusted by default: the framework and
its plugins are the trusted computing base that builds the bundle and
runs the sandbox, and their return types (TunnelHandle, BashResult,
agent results) are inert. This keeps the framework's own paths
(Proxy.start, bash.run, agent adapters) working without setup. A
workload's own return types are refused by default; opt in with
safepickle.allow_module(prefix) / allow_callable(module, name), or set
AGENTIX_PICKLE_TRUST=1 to trust the sandbox fully. A refusal raises
agentix.RestrictedUnpickleError.

Only the sandbox->host direction is restricted (client._unpickle_value,
both the SIO and HTTP result paths). The sandbox-side decode of
host-supplied arguments/context stays plain pickle -- the trusted
host->sandbox direction.

Tests: non-allowlisted callables (subprocess.check_output/Popen,
os.system, eval) refused; attribute-access helpers (operator/_operator
attrgetter/itemgetter/methodcaller, getattr) refused; refusal does not
import the named module; object-dtype numpy arrays gate nested globals;
first-party return types (TunnelHandle, BashResult) and permitted value
types + builtin exceptions + workload-via-allow_module round-trip; trust
bypass; end-to-end refusal over a real remote() call. PROTOCOL.md
documents the boundary; RestrictedUnpickleError exported.

Closes #116.

Co-Authored-By: Claude <noreply@anthropic.com>
The unpickling paragraph still described the pre-exact policy: blanket
agentix.* prefix trust and the deleted allow_module()/allow_callable()
surfaces. Name the six individually registered first-party return types
and point extension at allow_type(Class), matching safepickle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Ray-cluster build notes are unrelated to the restricted unpickler;
untrack them so the stage E diff stays on-topic. The file stays on disk
as an untracked local note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Meirtz Meirtz merged commit 6e971bc into master Jul 3, 2026
5 checks passed
@Meirtz Meirtz deleted the security/restricted-unpickler branch July 3, 2026 07:35
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.

Host-Side Unsafe Deserialization of Sandbox Return Values

1 participant