Harden sandbox return boundary by replacing host-side pickle deserialization#118
Harden sandbox return boundary by replacing host-side pickle deserialization#118FatPigeorz with Copilot wants to merge 3 commits into
Conversation
Review — the concern is real, but this fix breaks the plugin ecosystem as written#116 is a genuine, present issue on master. The host decodes return bytes that originate inside the sandbox using But the fix, as written, breaks essentially every non-dict/bytes remote target. msgpack can't encode a dataclass, and master's codec only has
And even a pydantic return decodes host-side to a plain dict, so every example's attribute access — It also contradicts the stated direction and conflicts with #122. ROADMAP is explicit that pickle is the default for "args, kwargs, and return values" and msgpack is a future optional path alongside pickle, not a replacement. Meanwhile #122 keeps returns on pickle and deletes the codec's extension hooks entirely — so #118 and #122 edit the same Design options for the maintainers (this is a real decision, not a mechanical fix):
I'd lean (1) as the least-disruptive way to actually address #116 while honoring the ROADMAP surface, but it's your call. Flagging rather than merging or closing, since whichever way this goes needs to be reconciled with #122's codec changes. |
|
Closing as superseded. The host-side deserialization concern (#116) is being addressed on a different axis: a restricted-unpickler allowlist that fixes the escape vector without changing the return-value wire format — the msgpack-for-returns approach here would break every dataclass-returning shipped target (BashResult, ClaudeCodeResult, PrepareEnvResult, TunnelHandle, …) and conflicts with the codec work landing via the PR #122 split. Thanks for the initial fix; the direction we're taking keeps pickle as the default per the roadmap. Tracking the hardening under #116. |
* security: restricted unpickler on the sandbox->host return boundary (#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> * docs: design exact-type restricted unpickling * security: add exact pickle type opt-in * test: cover exact pickle policy boundary * security: restrict pickle globals to exact values * test: cover exact first-party pickle values * test: tighten safepickle compatibility assertions * style: format restricted unpickler * docs: add exact-type unpickling implementation plan * test: cover warm pickle extension cache bypass * security: reject pickle extension opcodes * test: make restricted decode regressions harmless * docs: list the six exact default return types in PROTOCOL 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> * style: format public export list Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: keep agentix-ray-build skill out of the stage E diff 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> --------- Co-authored-by: Claude <noreply@anthropic.com>
Host-side
RuntimeClientwas deserializing sandbox return payloads withpickle.loads, allowing sandbox-influenced objects to execute code on the host during decode. This change moves return-value transport to msgpack decoding on the host boundary while preserving existing callable/argument behavior.Boundary hardening (host decode path)
pickle.loads(...)tounpack(...)(msgpack codec).Return-value wire format change (worker → host)
RemoteResponse.valuewith shared msgpack codec (pack(result)) instead of pickling return objects.pickle.dumps((args, kwargs))) to keep callable invocation behavior stable.Protocol/docs alignment
Test updates + security regression