Sandbox external children, not grant bodies
grant remains the sandbox boundary by confining every external child, not by
re-execing the interpreter around the grant body. A grant is a dynamic effect
scope: it folds the live stack, checks the effects ral performs, and renders a
child policy for effects an admitted process may perform after spawn. The ral
interpreter stays in the caller process.
The split is semantic, not just mechanical:
grantis a meet. Nested grants compose by intersection over authority. That algebra belongs to the evaluator’s dynamic context, not to a hidden process boundary.- A sandbox is a spawn boundary. Seatbelt, bwrap, and AppContainer constrain a process tree. They should wrap the process whose behaviour is untrusted: the external command ral is about to run.
- RAL-owned effects stay in process. Structured filesystem primitives,
redirects, editor access, shell-state mutation, and the top-level exec judgment
are decided by
capability::check_*(&Context, …)before the effect happens. - Child-owned effects are kernel-backed. When an admitted external command is
spawned under a restrictive fs grant, ral renders the current effective policy
and enters the OS sandbox in that child before
execve/process launch. A child that reads, writes, or re-execs behind ral’s back is then held by the kernel layer where the backend can express it.
The endpoint-shaped net story leaves; an all-or-nothing offline child
sandbox axis may stay. ral has no network primitive for an in-process gate, so a
network-denying grant can only mean “spawned children get no network”. That is
honest on Linux (--unshare-net) and macOS (omit (allow network*)), but a
Windows no-op is not acceptable: a backend that cannot enforce offline mode must
fail closed when that mode is requested.
On Windows the credible designs are:
- AppContainer identity. Launch the child in a per-profile AppContainer with no network capabilities, or use that AppContainer SID as the identity a WFP rule matches. This gives a per-spawn-ish principal; the cost is making ordinary desktop tools run under AppContainer filesystem/DLL restrictions and ACLing the scratch/allowed paths correctly.
- WFP dynamic filters keyed to a unique child identity. WFP can block at ALE
connect/listen/resource-assignment layers, but image-path rules are too coarse:
a
ral --ral-bundled-toolchild has the same image as the parent, and agitrule would hit unrelatedgitprocesses while installed. A filter is acceptable only if it keys on a unique package/AppContainer SID or a brokered per-child identity. A PID-only design needs a callout/driver, not plain static filters. - Fail closed. Until one of those exists, offline mode on Windows is unsupported rather than advisory.
This keeps the language’s founding promise: an exarch turn or ral script runs under a grant frame whose admitted commands cannot freely read and write outside the frame. What changes is the locus of the process boundary. The interpreter is trusted policy machinery; external programs are the untrusted computations.
Consequences
- Ordinary
grant { … }evaluation is local again. Closures, handlers, audit ownership, cancellation, and shell state are not transported across IPC merely because a block mentionsfs. WireMobile,run_confined, the authenticated confinement marker, and the parent-side sandbox IPC cancellation path stop being load-bearing for ordinary grant scope. They may survive only for an explicit whole-interpreter sandbox or other child-eval consumers.- The in-process/OS split remains. Removing body re-exec does not collapse the two enforcers: the evaluator still judges ral-dispatched effects, and the kernel still confines a spawned program’s own effects.
- Bundled commands must choose one side of the line. If they execute in-process,
their filesystem effects go through
check_fs_op; if they cannot do that cleanly, they run as an exec image and receive the same child sandbox as any other external. - A whole-script sandbox is a separate construct. For untrusted ral source or
defence in depth against interpreter/plugin bugs, add an explicit
sandbox { … }/ral --sandboxboundary that evaluates a complete turn in a confined interpreter child and returns only a serialisable result. It is not the default meaning ofgrant.
Safety invariant
There must be no third path for filesystem effects. Under an fs-restricting grant, every operation that can touch the filesystem is either:
- ral-owned — evaluated in the interpreter and checked by
capability::check_fs_op(&Context, …)on the canonical path before the syscall; - child-owned — launched as a process under the effective
SandboxProjection; - outside the grant effect surface — startup/profile machinery whose authority is deliberately not user-code authority.
The old body re-exec bought defence in depth by putting even unchecked
interpreter syscalls under Seatbelt/bwrap. This design removes that accidental
floor, so the replacement proof obligation is explicit: redirects, globbing,
module loading, temp-file creation, structured reads/writes, helper transports,
and bundled commands are audited into one of the three buckets above. A bundled
command that cannot route every filesystem access through check_fs_op must run
as an exec image and receive the same child sandbox as an external.
The acceptance test is negative: every filesystem primitive and bundled head has
a denied-fs regression that proves it fails before the syscall or inside the
child sandbox, not by convention.
Bundled commands
Bundled coreutils/diffutils/ripgrep heads follow bundled-tools-as-exec-images. The fd/cwd/env bug that pushed them toward helper re-exec is real; the fix is to make them ordinary command images, not to reintroduce grant-body re-exec.
- Inline is ambient-only. A bundled head may run in-process only in the clean
terminal case: no active sandbox projection, no redirects or fd dups, no
capture/audit tee, no pipeline staging, no env override, and no logical
cwd/process cwd mismatch. The small uucore-global mutex protects only the
upstream exit-code cell; it is not authority and it does not make fd/env/cwd
mutation thread-local.
This is the Windows cost valve: the common interactive
ls/cat/pwdshape pays no process creation at all. - Every fs-restricting grant forces child placement. Upstream uutils/ripgrep
code opens paths internally and does not call ral’s
check_fs_op, so it is child-owned for grant purposes. The parent first checks exec authority, then launchesral --ral-bundled-tool <tool> …under the same effectiveSandboxProjectionan external command would receive. - Redirects are child stdio, not parent fd surgery.
>,2>,<, and2>&1are realised by the ordinary childCommandplumbing: owned files, pipes, inherited handles, and a child-local Unixpre_execdup2when needed. The parent never temporarily rewires fd 0/1/2 to make an in-process library call look like an exec. - Pipeline byte stages are direct children. A byte-only bundled stage is a
process-stage image with normal stdin/stdout/stderr and, if needed, a start-gate
fd/handle. It has no
StageJob::Uutils, noUutilsSnapshot, and noChildEvalResponse; value-edge bundled stages remain evaluator helper work.
Pipeline helpers
The pipeline-stage re-exec stays. It is the principled remaining re-exec mode, because a process-staged pipeline is already an OS process boundary, not a lexical effect boundary pretending to be one.
- Pure value pipelines do not spawn. They remain the parent-side fold
x | f = f !{x}. - A byte pipeline stage that is ral code is a subshell. The helper owns the stage’s stdin/stdout/stderr, process group / Job Object membership, foreground handoff, cancellation, and “changes do not flow back” state boundary. Those are process facts; evaluating the stage in the parent thread would reintroduce the same fd/cwd/env global-state hazards this decision is removing.
child_evalbecomes narrower, not absent. It should retain thePipelineStagerunner and value-edge force/report path. It losesChildKind::Sandbox, mobile-returning grant-body evaluation, authenticated sandbox markers, and the parent-side sandbox IPC watcher.- Grant semantics inside a helper are the same as in the parent. The helper is trusted ral policy machinery carrying the grant stack. Its ral-owned filesystem effects are checked in-process; any external or bundled child it spawns is launched through the per-command sandbox path under the current effective fs projection.
- Bundled byte stages do not use this helper. They are direct
ExecImage::BundledToolchildren unless they touch a value edge and therefore need evaluator semantics.
Implementation deviation: direct byte stages are the NoTerminal case only
The “byte stages are direct children with a start-gate fd” letter above is
realised narrowly. direct_spawnable (runtime/pipeline/resolve.rs) admits the
StageLaunch::Direct path only when the stage carries no value edge and none
of the three conditions a bare direct child cannot serve hold: the pipeline does
not own the controlling terminal (NoTerminal), the stage has no redirects, and
no !{…} audit is capturing bytes. A byte stage that is foreground, redirected,
or byte-audited routes through the existing HelperEval re-exec instead of a new
direct-child-with-start-gate launcher. Those three conditions are exactly the ones
that need helper capabilities the helper already provides — park-on-stop and
tcsetpgrp handoff, redirect-as-child-stdio, and byte accounting — so reusing the
helper avoids building a third launch path that would reimplement them. This does
not weaken the safety invariant: the helper carries the grant stack and spawns its
external/bundled child under the same per-command SandboxProjection a direct
child would (child_eval.rs), and the denied-fs regressions cover the helper path
as a non-third fs path. The cost is one extra ral re-exec per affected
host-external stage; bundled stages pay nothing (their direct child is already a
ral --ral-bundled-tool process). On Windows the terminal plan is always
NoTerminal, so the foreground branch never applies there and the only residual
exposure is host-external stages with a redirect or under a byte-capturing audit.
The full direct-gate refactor remains available for exact letter fidelity if that
extra re-exec ever shows up as a latency cost (most plausibly on Windows).
What disappears
The large deletion is not the OS backend renderers; those still define what a child may do. The deletion is the machinery that made a lexical block pretend to be a process:
- the evaluator branch that detects a restrictive grant body and swaps local evaluation for confined evaluation;
- sandbox IPC for ordinary grants: request/response framing, endpoint setup, and the one-body child server;
- the authenticated
RAL_SANDBOX_ACTIVEmarker as a condition for skipping nested grant confinement; ChildKind::Sandboxand the mobile-returning child-eval path used only to make body re-exec observationally local;- parent-side cancellation watchers whose only job is to interrupt a child interpreter while the parent is blocked on synchronous sandbox IPC;
- endpoint-shaped network policy, the Windows advisory/no-op
netpath, and tests that treat network denial as more precise than offline child confinement.
What remains is smaller and more direct: a per-external command launcher that
takes the already-folded SandboxProjection and starts that command under the
platform sandbox.
Implementation direction
- Delete the evaluator path that re-routes a restrictive grant body through sandbox IPC.
- At external dispatch, compute the effective grant once, perform the normal
check_exec_args, then spawn the command through a platform sandbox launcher when the effective filesystem policy is restrictive or offline network mode is requested. - Replace
netwith an explicit offline child-sandbox axis, or delete it if that narrower semantics is not accepted. No endpoint vocabulary, and no advisory backend: unsupported offline mode is an error. - Re-scope
child_evalto pipeline/helper execution and any future explicit whole-interpreter sandbox. It is no longer howgrantacquires confinement. - Keep
capability::check_*(&Context, …)as the only authority-decision module; only the action taken after a successful external-spawn verdict changes.
Implementation order
Do not delete grant-body confinement until per-command confinement exists. The safe order is:
- Settle network semantics first. Delete endpoint-shaped policy and the
Windows advisory/no-op path. If network remains, it is only an offline
child-sandbox axis (
deny network for spawned children) and unsupported backends fail closed. If even that is rejected, remove the field entirely. - Land bundled tools as exec images. Introduce the resolved command image
split (
HostvsBundledTool), add the hidden bundled-tool entrypoint, route byte-only bundled pipeline stages through the direct child path, and deleteStageJob::Uutils/UutilsSnapshot. Keep the clean-terminal inline fast path. - Add per-command sandbox launching. Make the ordinary command runner consume
the current effective
SandboxProjectionand startExecImage::Hostand child-placedExecImage::BundledToolunder the platform backend. On Linux this is the bwrap command constructor; on macOS/Windows it may still be a tiny ral launcher process, but it launches one command, not a grant body. - Flip
grantbody evaluation back to local. Remove the transport branch that sends restrictive grant bodies throughrun_confined; keepcheck_fs_opand external dispatch tests red at first if any effect escaped the two-path invariant. - Prune obsolete confinement IPC. Delete
ChildKind::Sandbox, mobile-returning sandbox child eval, authenticated sandbox markers, sandbox-request IPC, and the sandbox IPC cancellation watcher. KeepChildKind::PipelineStageand the helper protocol. - Audit denied-fs coverage. Add or update negative tests for redirects,
structured reads/writes, globbing, module/source loading, helper transports,
external commands, bundled commands, and bundled byte pipelines. A failure must
occur at
check_fs_opor inside the child sandbox.
See also grant, two-enforcers, capability-enforcement, and child-eval-unification.