Port-Isolated Code Medium

Copy Markdown View Source

The port code medium is Cantrip's default sandbox for LLM-written Elixir. It preserves the important part of the code medium — the entity still writes Elixir with persistent bindings — while evaluating that code through Dune in a child BEAM process.

The default sandbox: :port path is deliberately not raw child Elixir. Dune denies ambient filesystem, system command, process, spawn, node, and similar capabilities. The port boundary keeps the evaluator, hot-loaded modules, and child-spawned work out of the host BEAM. Gates and package composition cross the boundary only through explicit RPC frames.

Boundary

The parent BEAM owns:

  • the public Cantrip API and entity supervision
  • provider calls
  • gate registration and execution
  • filesystem root validation
  • credential redaction
  • loom storage and child-turn grafting
  • telemetry and streaming events
  • hot-load policy validation

The child BEAM owns:

  • Dune-restricted evaluation of LLM-written Elixir
  • persistent code-medium bindings for the session
  • modules hot-loaded through compile_and_load
  • raw processes spawned only when using the explicit :port_unrestricted escape hatch

On evaluation timeout, the parent closes and kills the child OS process. That ends the child session and any processes spawned inside it.

Child Runner

By default, Cantrip starts the child directly:

elixir -pa ... -e "Cantrip.Medium.Code.PortChild.main()"

Set %{port_runner: [executable, arg1, ...]} in the circle wards, or pass port_runner: [...] to Cantrip.Familiar.new/1, to prepend an OS/container runner before that command. This is optional defense in depth for deployments that also want mount, network, CPU, memory, or user controls around the child process.

The Familiar's ordinary default is sandbox: :unrestricted for trusted operator-local work. Passing port_runner: [...] to Cantrip.Familiar.new/1 without an explicit sandbox selects sandbox: :port so the runner is actually used.

Cantrip tests that the configured runner is used. Cantrip does not verify the security properties of an arbitrary runner; that belongs to the deployment.

Protocol

Parent and child communicate over an Erlang port using length-prefixed Erlang external terms. The main frames are:

{:init, binding}
{:ready, child_pid}
{:eval, ref, code, env}
{:gate_call, ref, gate_name, args}
{:gate_result, ref, observation}
{:compile_request, ref, args}
{:compile_allowed, ref, payload}
{:compile_denied, ref, observation}
{:api_call, ref, function, args}
{:api_result, ref, reply}
{:eval_result, ref, binding, value, terminated?, captured_output}
{:eval_error, ref, binding, reason, captured_output}

The child receives gate closures. Calling read_file.(...), search.(...), or done.(...) sends a request to the parent and returns the parent result to the child code.

Public API Proxies

Inside the child, ordinary calls to:

are rewritten to injected proxy closures. The parent constructs and runs the children, applies parent-context inheritance, grafts child turns into the loom, and sends serializable results back to the child. The entity can write normal Cantrip composition code without receiving authority over the parent BEAM.

Hot Loading

When compile_and_load is present in the circle, the child can request a hot load. The parent validates the request against compile wards:

  • exact allowed module names
  • allowed compile paths
  • allowed source hashes
  • allowed signer keys and signatures

If validation passes, the child compiles and loads the module in the child BEAM only. The parent framework VM is not modified. In the safe port evaluator, newly loaded modules are added to that child session's Dune allowlist, so the same turn can call the module after a successful compile_and_load.

Namespace-based compile wards are deliberately unsupported. Use allow_compile_modules with exact module names; requests that include the deprecated allow_compile_namespaces ward fail loudly instead of silently granting or denying a different authority than the caller intended.

Escape Hatches

sandbox: :port_unrestricted keeps the child process and timeout cleanup but evaluates raw Elixir in that child. It exists for trusted experiments and for testing process-kill behavior. It is not the Familiar default.

sandbox: :unrestricted uses the legacy host-BEAM evaluator. It is for trusted local development only.

Dune Variant: Deliberately Restricted

sandbox: :dune is a separate code-medium variant that evaluates LLM-emitted Elixir inside the host BEAM under Dune's language restrictions, without the port boundary. It exists for deployments that want in-process language restriction without paying for an external child BEAM.

The Dune variant has a deliberately different binding surface than the default port sandbox. The port sandbox exposes Cantrip.new, Cantrip.cast, and Cantrip.cast_batch as proxied calls inside the child, plus the gate functions, plus common Elixir control flow. The Dune variant does not mirror the full public package surface and additionally restricts several language operations (binding/0, try/1, Code.ensure_loaded?/1, plus the cross-boundary capabilities all sandboxes block: File.*, System.*, Process.*, spawn, Code.load_*).

Declared gates still flow through the parent in both variants. If a Dune circle grants mix, read_file, search, or any other gate, the entity can call that gate subject to the gate's own dependencies and wards; Dune only changes the language surface around those explicit capabilities.

This divergence is intentional: Dune is a security-language boundary mechanism. If your entity needs the full public API surface or in-medium introspection, use the default sandbox: :port boundary. If you specifically need in-process language restriction with a smaller binding surface, use sandbox: :dune and write circle/prompt content that fits that surface.

Don't teach entities running under sandbox: :dune patterns that the port sandbox supports (e.g. binding(), try-rescue, Code.ensure_loaded?) — the prompt should match the medium variant in use.

Remaining Deployment Responsibility

The default port sandbox denies ambient language capabilities and protects the host BEAM. If a deployment also needs operating-system isolation — mount namespaces, network egress policy, CPU/memory quotas, or a distinct OS user — apply those limits with :port_runner or around the whole host process.