Sandbox (trial isolation)¶
Subprocess fork-trial isolation (CONTRACT v1.5 #1). run_forked_trial
runs candidate pack code against a fork of a saved run inside a
fresh-interpreter child, materialized from artifacts pinned by a
bundle hash — so the parent process stays out of the blast radius of a
runaway (memory/CPU) or corrupt in-process state, and the bytes trialed
are the bytes a proposal recorded.
For the design, the worked recorded-segment-replay example, the
extra_packs cross-pack path, and the honest per-platform limits, see
trial-isolation-design.md in the repository root.
Import path
The sandbox surface lives under activegraph.sandbox and is
imported from there (from activegraph.sandbox import
run_forked_trial, preflight); it is intentionally not re-exported
at the top level.
Crash/state isolation, not a security sandbox
A fresh interpreter with rlimits stops runaway memory, CPU, and
parent-state corruption. It does not confine syscalls, the
network, or filesystem access — that is host territory (containers,
seccomp). The env allow-list is closed (PATH/HOME/LANG plus
an explicit env_passthrough); the child's PYTHONPATH is a
computed code-location channel, not an ambient forward. The memory
cap (RLIMIT_AS) is enforced on Linux and announced-unavailable
(never crashed) on macOS/Windows, where the wall-clock kill and
event budget remain the active nets.
Running a trial¶
Fork the parent at at_event and trial the candidate pack in
a fresh subprocess. CONTRACT v1.5 #1.
The fork is created HERE, in the parent process, with full
fork() semantics (lineage recorded, promote-block cut guard);
the child receives only the fork's run id. scenario is
"relative/path.py" or "relative/path.py::func" inside the
CANDIDATE's pack root — the function (default main) is called
with the fork's Runtime and drives the trial; empty means
just run_until_idle(). The scenario contract is
def main(rt): ....
The default is candidate-only isolation: the child loads
nothing but the candidate, so the trial exercises it apart from
every other pack's behaviors. extra_packs is the opt-in for
cross-pack interaction trials (CONTRACT v1.5 #1 addendum 1b):
each entry is materialized in the child exactly like the
candidate — bundle hash verified before import, manifest schema
+ two-way surface check when required — and loaded, in order,
BEFORE the candidate. Any extra pack failing its pins is
materialization_failed for the whole trial; the scenario
still resolves inside the candidate's root only.
Deterministic and key-free by default (max_llm_calls=0, empty
environment pass-through). Returns a :class:TrialReport; never
raises for in-trial failures — those are outcomes. Raises only
for parent-side setup problems (bad store, bad fork point), with
the same errors Runtime.load / fork() raise.
Startup preflight¶
Verify a trial child can START under the sandbox env on this box.
Spawns the child with a null job that exercises the full startup
path a real trial hits — importing activegraph under the sandbox
env AND applying resource limits (v1.7.1: a go/no-go gate that
skipped limit application passed on macOS where every real trial
then crashed on RLIMIT_AS). No fork, no store, no candidate.
Returns a tuple of degradation warnings — empty when every net
applied cleanly, non-empty (and logged) when a net degraded on this
platform (e.g. the memory cap on macOS). Raises
:class:SandboxStartupError with the child's stderr tail if a child
cannot start at all. So on macOS this PASSES with a memory-net
warning rather than pass-then-crash; on a box that cannot import
activegraph it RAISES with the real cause. Consumers call it once at
boot to fail loud or learn what degraded before the first trial.
Pass the same limits a real trial will carry to probe that exact
configuration; the default probes with a representative memory cap.
Bases: RuntimeError
A trial child could not START under the sandbox env.
Raised by :func:preflight when the child fails before it can run
any trial — the common cause is the sandbox env being unable to
import activegraph on this box (the exact restricted-env break
the explicit package-path channel exists to prevent). The message
carries the child's stderr tail so the cause is never opaque.
Inputs¶
Where the child materializes the candidate pack from.
expected_bundle_hash is the external pin (the §4 walk WITH
manifest.toml, per the v1.4 bundle-hash amendment) — the child
verifies it via activegraph.packs.manifest.verify_bundle_hash
before importing anything, so the bytes trialed are the bytes the
proposal recorded. manifest_required=True additionally runs
load_manifest + verify_surface against the live Pack,
making the trial child the first consumer of the full manifest
chain end-to-end.
The trial's resource nets. Zero/None disables a given net.
max_llm_calls=0 (the default) means key-freedom is
STRUCTURAL: the child configures no LLM provider at all, so a
candidate with LLM behaviors fails loud at registration
(MissingProviderError) rather than reaching a network. A
positive cap is accepted and recorded for a future
provider-wiring seam; the v1 child never configures a live
provider either way. env_passthrough is the ONLY ambient parent
environment forwarded beyond the closed allow-list
(PATH/HOME/LANG) — the helper never forwards the
environment wholesale, so parent API keys don't leak into
candidate code by default. The child's PYTHONPATH is NOT an
ambient forward: it is computed from the parent's resolved
sys.path so the child can import its own code (see the module
docstring's "two channels").
Result¶
What a trial produced. Store-derived numbers, child-signaled shape.
events_appended and behavior_failures are re-read from the
fork's run in the store by the parent AFTER the child exits — the
stdout tail is a signal only, and any disagreement resolves in
favor of the store. Richer evidence (tracebacks, diffs) is read
from the fork's log directly: Runtime.load(store, run_id=
report.fork_run_id) then trace.failures() / diff().
warnings (v1.7.1) carries any resource net that DEGRADED on
this platform — most notably the memory cap (RLIMIT_AS) on macOS,
which the Darwin kernel refuses. A degraded net is announced, never
silently skipped: the same strings are also folded into detail,
and the parent logs each one.