Proposal: LLAR Formula Sandbox
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 5
- Forks
- 4
- Avg merge
- 2d 13h
- Merged PRs (30d)
- 22
Description
Proposal: Execute LLAR Formula Callbacks with Sandbox
1. Summary
Use xgo-dev/sandbox to execute LLAR Formula callbacks in gVisor Sentry while preserving ordinary Go calls and captured-object updates. LLAR selects the filesystem and environment, calls Sandbox.Run(func() { build(ctx) }), and inspects syscalls at the return boundary of platform.Context.Switch. The library transfers the closure's object graph into a guest running the same executable and writes the completed graph back into the original host objects.
This replaces the earlier CRIU proposal. The execution boundary is a function call, not a checkpoint of the LLAR process. The original host remains alive and continues after Run.
2. User Stories / Motivation
| Need | Concrete example |
|---|---|
| Formula safety | Formulas are build scripts. If review admits a library carrying malicious code, llard may execute it during a build and expose the entire build cluster to attack. |
| Reproducible inputs and build environment | Two machines supply the same source, dependency files, toolchain and explicit PATH/LANG, rather than silently using whatever the host exposes. |
| Build records | LLAR observes that a callback attempted openat, read and close, including operations performed by native libraries and child tools. |
| Calling compatibility | An existing ixgo callback still receives *formula.Context; after it finishes, the caller reads its updated metadata from the original context. |
The common need is to isolate Formula execution while LLAR retains control of its build environment and can supply the context and consume its updates.
2.1 Design Goals
Build a lightweight sandbox for LLAR with minimal integration and runtime overhead:
- Flexible execution: Choose where ixgo callbacks run while preserving their interfaces and state updates.
- Build control: Keep LLAR in charge of inputs, filesystems, environment and execution observation.
- Concurrency: Support future concurrent builds in llard with low startup, state-transfer and resource costs, without pausing unrelated host work.
3. Candidate Execution Models
3.1 Put the Program Inside the Sandbox
Running llar make inside Sentry is viable: its supervisor can configure mounts and environment, inspect syscalls, and LLAR can still use ixgo. The limitation is granularity: one invocation builds several libraries, while we need control over each build.
- Independent build environments. If libraries A and B build in the same process, changes to process-wide state made by A's native code can affect B, such as a changed
PATH. The outer sandbox isolates the whole build from the host, but does not isolate A from B or give each its own mounts and permissions. Scoped environment APIs help, but do not replace process isolation. - Build-specific records. Sentry can observe syscalls from the entire invocation, but the stream mixes dependency downloads, cache access and the builds of A and B. Process/thread IDs alone do not identify the active Formula. A separate execution boundary for each build lets its inspector attribute operations, including child-tool activity, to that build.
We therefore need a per-Formula execution and observation boundary. This does not yet determine how to send work into it; a separate worker per library is also a candidate. The following sections examine how to preserve the existing callback and object-state contract across that boundary.
3.2 Move Process State with CRIU
Checkpoint/restore suggests exporting LLAR's process state, running the Formula in Sentry, then restoring the result to the host. Even assuming that integration works, returning to the original, still-running host raises a memory-consistency problem:
t0: snapshot host -> guest starts with the same state
t1: host advances -> allocations, object updates and runtime state change
guest advances -> Formula updates its copy of that state
t2: guest finishes -> which changes can overwrite the original host?
Equal virtual addresses do not imply equal contents or allocation lifetimes. Restoring guest pages over the original host can discard host updates and conflict with its current heap/GC state. CRIU restores checkpointed processes; that operation does not merge two independently evolving states.
Without such reconciliation, this whole-process round trip would require keeping the original host stopped throughout guest execution to prevent divergence. That stalls unrelated LLAR tasks and its control work for the duration of a build. We therefore need a smaller transfer boundary: the selected call's state, with the host free to continue unrelated work.
3.3 Keep LLAR Outside and Invoke a Worker Through RPC
RPC provides such a call boundary. LLAR sends a build request to a sandboxed worker and receives its result:
sequenceDiagram
participant L as LLAR host
participant R as RPC transport
participant W as Sandboxed worker
L->>L: Prepare Formula and Context
L->>R: Encode BuildRequest
R->>W: Send request
W->>W: Reconstruct arguments and call onBuild
W-->>R: Encode BuildResult
R-->>L: Send result
L->>L: Apply result to host Context
The difficulty is expressing the existing build(ctx) call in BuildRequest and BuildResult:
1. The protocol and wrapper do not carry an arbitrary Go closure.
source := os.DirFS("/src")
name := "hello.txt"
build := func() ([]byte, error) { return fs.ReadFile(source, name) }
Go's gob cannot encode build. Sending {"root":"/src","name":"hello.txt"} only describes data: the worker still needs code to construct os.DirFS and call fs.ReadFile. A wrapper executes its registered operations; a host function address and captures alone do not provide matching code. Each custom filesystem similarly needs its concrete implementation or a proxy.
2. Preserving the call still requires recursive state encoding.
Consider a callback capturing two inputs that share one counter:
build captures:
left -> Input.Counter -> Counter { N: 0 }
right -> Input.Counter -> the same Counter
build(): left.Counter.N++; right.Counter.N++
result: both observe N == 2
The encoder must descend from the captures through both inputs to the counter and preserve their shared reference. Two independent copies would each end at 1, changing the behavior. On return, the original host counter must become 2, so the result also needs an association with that object. Interfaces and nested callbacks add concrete types and captures to this traversal. An RPC request/result envelope still requires these encoding and reconstruction rules.
RPC can be extended to handle these cases, over a network or local IPC. But if preserving Formula calls already requires recursively encoding their state, what should that traversal start from? XGo classfiles give us a concrete answer.
4. From Classfile State to Closure Transfer
4.1 Formula Source and Generated Go
LLAR Formulas use XGo classfiles. Their generated Go reveals where the state an RPC request would need to carry actually lives: class fields belong to one instance, and its callback captures that instance.
Here is Readfile_llar.gox. Its first var block declares class fields:
import "io/fs"
import "os"
var (
source fs.FS
filename string
builds int
)
id "example/readfile"
fromVer "1.0.0"
source = os.dirFS("/src")
filename = "hello.txt"
onBuild ctx => {
builds++
data, err := fs.readFile(source, filename)
if err != nil {
ctx.Errs.Add(err)
return
}
ctx.setMetadata string(data)
}
For the walkthrough, /src/hello.txt contains the five bytes hello with no newline. Before the call, builds is zero and the context's metadata is empty. A successful call increments the instance field to one and sets the metadata to "hello".
LLAR's gox.mod registers *_llar.gox with the ModuleF base class. The following Go was generated using the XGo version selected by LLAR's go.mod, with only generated markers, the unused generated constant and source-location directives omitted:
package main
import (
"github.com/goplus/llar/formula"
"io/fs"
"os"
)
type Readfile struct {
formula.ModuleF
source fs.FS
filename string
builds int
}
func (this *Readfile) MainEntry() {
this.Id("example/readfile")
this.FromVer("1.0.0")
this.source = os.DirFS("/src")
this.filename = "hello.txt"
this.OnBuild(func(ctx *formula.Context) {
this.builds++
data, err := fs.ReadFile(this.source, this.filename)
if err != nil {
ctx.Errs.Add(err)
return
}
ctx.SetMetadata(string(data))
})
}
func (this *Readfile) Main() {
formula.XGot_ModuleF_Main(this)
}
func main() {
new(Readfile).Main()
}
To reproduce the generation, place the example under formula/demo/sandbox/Readfile_llar.gox in an LLAR checkout and use the matching XGo installation:
xgo go ./formula/demo/sandbox
go test ./formula/demo/sandbox
This example was generated with github.com/goplus/xgo v1.7.6-0.20260818050008-fd36b6192e30 and the generated Go passed compilation. This check demonstrates the transformation; it is not a Sentry execution result for this new example.
4.2 The Callback Already Has an Instance State
In the generated code, source, filename and builds are all fields of Readfile. The registered callback captures this: reading the source and incrementing the counter are accesses through the same receiver.
build(ctx)
callback
captured this -> Readfile
ModuleF -> registered callbacks and configuration
source -> fs.FS containing os.dirFS("/src")
filename = "hello.txt"
builds = 0
argument ctx -> formula.Context
Proj, Out, errors and other reachable fields
ModuleF.OnBuild is the registration method shown in the generated code. It stores a func(*formula.Context), which LLAR's loader exposes as loaded.OnBuild for invocation.
The callback therefore supplies an entry point to the Formula instance, while its argument supplies the build context. Following these references reaches the state we would otherwise have to describe in an RPC request. The classfile transformation makes that boundary explicit without collecting the entire host process.
The language rule has limits: the first class-field var declaration becomes instance fields, but later package-level declarations and ordinary Go package globals are not all rewritten. The example establishes a boundary for instance-owned Formula state, not for every possible global dependency.
4.3 Derive Closure Transfer from That Boundary
We now have both parts of the design: preserving the RPC call requires recursive state encoding, and classfile callbacks already reach their instance state through captures. Instead of defining a separate request schema for that same graph, we can encode the closure environment directly. This is still serialization across a process boundary; it uses the existing call as the root.
A Go closure can describe that call directly:
build := loaded.OnBuild
err := sandbox.Run(func() {
build(ctx)
})
The outer function captures build and ctx. The codec follows supported typed references from these captures, recreates the graph in guest-owned memory, and calls the restored function. On success, object IDs associate returned changes with the original host objects. Those mutable objects must remain exclusively owned until return; unrelated host work can continue without the whole-process pause discussed above.
Host LLAR Guest: same executable
prepare build and ctx
fn = func() { build(ctx) }
|
+-- code identity + object graph --> restore fn, build and ctx
fn()
-> build(ctx)
save changed graph
<-- object IDs + changed values ----+
update original ctx
continue after Run
For a concrete os.DirFS("/src") value, the state is the directory path. If its native implementation is also available in the guest, restoring that value lets the filesystem interface work there. After restoration, filesystem methods run in the guest and their syscalls resolve /src through its Sentry filesystem. This does not transfer an already-open host file descriptor. An fs.FS backed by unsupported live resources remains unsupported; implementing the interface alone does not make every implementation transferable.
The Formula instance and context are the business-state roots, not a guarantee that only two flat structs are transmitted. Following the current ixgo callback also reaches interpreter/program objects, native callback environments and dynamic types. References can lead back from the embedded ModuleF to the instance, so the transfer must preserve cycles and shared objects. The following encoding sections explain those dependencies.
4.4 Supply the Code with the Same ELF
Reconstructing values only supplies the data half of a callable. The worker also needs the code that interprets those values. Loading the same executable gives it the native implementations already used by LLAR, including methods of the concrete objects carried by the call.
The current native-function codec records absolute PCs. A receiving process must find the same code at those addresses. The implementation therefore requires the same non-PIE Linux ELF and supported Go ABI on both sides. Merely using the same source version, or running the same PIE executable at a different load address, does not satisfy that rule.
This choice reuses native code instead of shipping machine code with every callback. Static type descriptors can also be resolved against the executable. Heap addresses have a different rule: they may change completely. The codec replaces references to host objects with object IDs, then allocates correctly typed local objects and connects their pointers.
Code: host callbackPC == guest callbackPC
Data: host address H != guest address G is expected
ObjectID 7 identifies the corresponding object on both sides
A matching PC supplies code, not the contents or layout of a capture by itself. Native closure layout discovery still has to succeed, and every reachable value must be supported. Interpreted Formula code is another distinction: the native ixgo wrapper is in the ELF, while its interpreted program is data that must travel with the reachable graph.
4.5 Scope of the Proposed Boundary
The goal is to isolate a selected callback and its child tools while preserving supported arguments, aliases and writeback. LLAR continues to own mount selection, environment selection and syscall inspection; gVisor continues to implement guest syscalls.
This does not checkpoint the LLAR process, migrate its active goroutines or stacks, or provide a common host/guest Go heap. The current path loads the Formula on the host before transferring its callback, so Formula-controlled loading and initialization are not retroactively isolated. Process pooling, a syscall-completion/denial API, arbitrary FD migration, and a complete security review of returned object graphs remain separate work.
5. Ownership and Architecture
Host process
+-------------------------------+ +--------------------------------+
| LLAR Go runtime | | sentrylib.so Go runtime |
| | | |
| Sandbox.Run + state codec |---->| Sentry kernel, VFS, guest MM |
| | C | Task loop |
| LLAR Inspect callback |<--->| Context.Switch wrapper |
+---------------+---------------+ ABI +---------------+----------------+
| |
closure/result memfd systrap context handoff
| ThreadContext + futex
| |
+---------------v------------------------------------v----------------+
| Guest address space |
| Same LLAR ELF -> Go initialization -> private guest entry |
| Reconstructed closure -> ixgo callback -> Formula -> child tools |
| Guest application threads execute on systrap's sysmsg workers |
+--------------------------------------------------------------------+
| Owner | Responsibility |
|---|---|
| LLAR | Prepare the build context, mounts and environment; implement inspection and consume results. |
github.com/xgo-dev/sandbox |
Host API, closure/object transfer, private guest entry and host writeback. |
github.com/xgo-dev/sandbox/sentry |
Independently built .so; kernel startup, guest resources and the platform wrapper. |
| gVisor systrap | Stub/worker setup, syscall traps, saved thread contexts and execution handoff. |
LLAR imports the sandbox module, which loads the Sentry library through dlopen. The two Go runtimes in the host share a host address space; c-shared separates dependencies, not hostile native code. Guest isolation is supplied by Sentry and systrap.
6. Sentry Process Model
6.1 One Reusable Kernel, One Fresh Process per Run
sandbox.Run uses one global default Sandbox. Its first call starts a Kernel and later calls reuse it. An explicitly configured Sandbox has the same lifecycle and exposes Close(); no new constructor or changed Run signature is required.
Host process
default Sandbox (or one explicitly configured Sandbox)
Sentry Kernel, created on first Run
Run A -> guest process A -> its own state image and object graph
Run B -> guest process B -> its own state image and object graph
Run C -> guest process C -> its own state image and object graph
Run A finishing releases A's resources, not the Kernel.
Sandbox.Close stops active guests and releases that Kernel.
Each guest has its own Go runtime, heap and native package initialization. Concurrent calls may share a Kernel while using independent captured graphs. They are not several goroutines sharing a guest Go heap, and a guest process is not reused for the next callback. Each call transfers its full required type and object information again.
| Lifetime | Owned state |
|---|---|
| Host process | Loaded Sentry library, shared systrap platform resources and the default Sandbox. |
| Explicit Sandbox | Kernel, VFS infrastructure, timekeeper, memory-file allocator and process routing, until Close. |
| One Run | Fresh guest process/address space, child PID namespace, mount namespace, FD table, environment, snapshot memfd and inspector association. |
| One intercepted syscall | Register event and any temporary guest mappings created by that inspector invocation. |
The allocator backing file named llar-runtime-memory belongs to the Kernel. Each guest still has its own memory manager and virtual mappings. This file is distinct from the per-Run snapshot memfd llar-sandbox. A guest address is interpreted through its own address space, not as an address in LLAR's or the Sentry runtime's Go heap.
The current process setup shares the Kernel's root user, UTS and IPC namespaces. The fresh PID and mount namespaces should not be described as completely independent kernels or a complete multi-tenant policy. Two guests also see the same underlying host files if LLAR explicitly bind-mounts the same source into both.
6.2 Process Startup and Cleanup
| Step | Action |
|---|---|
| 1 | Acquire the Sandbox Kernel, creating and starting it on first use. Export this call's closure and allocate its own snapshot memfd. |
| 2 | Build this Run's mounts, environment, PID namespace and FD table. The state image is guest fd 3; standard streams use the existing imports. |
| 3 | CreateProcess loads the same LLAR ELF. Sentry changes only the guest's private main.main mapping to branch to the private guest entry. |
| 4 | StartProcess makes the new process runnable. Guest Go runtime and native package initialization run before the private guest entry. |
| 5 | Guest entry decodes the image, calls the restored fn(), encodes the returned graph and exits. |
| 6 | Sentry waits for this Run's tasks, including its children, to release their resources. Host reads and imports the result; the Kernel stays available for other calls. |
The branch patch does not change the executable file or host code. Users do not call an exposed Guest() entry. Sentry/systrap own native workers and syscall handoff; those workers are not a serialization of the host goroutine that invoked Run.
The host must exclusively own each captured mutable graph until its call finishes. Independent graphs can run concurrently; shared inspector state needs synchronization because inspectors for different Runs may overlap. Configuration fields must not change while calls are active, and a used Sandbox must not be copied. Close rejects new calls, terminates active guests and waits for their calls to finish. Calling it from its own inspector would wait on that inspector and is prohibited.
Sentry Task systrap sysmsg worker
enter underlying Context.Switch
publish runnable guest context --------> restore that guest's registers
wait for guest event run guest instructions
receive syscall/fault event <----------- report saved context
handle event, then enter Switch again
7. Encoding and Reconstructing State
The codec is adapted from gVisor's pkg/state object-graph traversal. Its byte transport and supported Go/runtime values are implemented in sandbox's internal/state. It is not a raw heap copy or a GC checkpoint. The diagrams below show logical records, not a replacement binary schema; object numbers are illustrative.
7.1 Values, References and Types
Three identities must be kept separate:
| Identity | Example | Meaning |
|---|---|---|
| ObjectID | Object 4 is one *Node target. |
Identifies a particular object so aliases and cycles reconnect to the same local storage. |
| Type reference | A type-table entry describes *Node. |
Describes how to allocate/interpret values; several objects can have the same type. Standard reflect and reflectx references identify their respective tables. |
| Function PC | The code address of callback. |
Selects existing executable code. It is not an object ID or a heap address. |
Most scalar and inline fields are stored directly inside their owner's record. A pointer field instead stores a reference, typically {Root: ObjectID}. References to an interior field or array range also carry a path from the owning object. Host addresses are used during export to detect identity and overlapping storage; they are not the destination addresses.
For example:
type Node struct {
Value int
Next *Node
}
n := &Node{Value: 10}
n.Next = n
root pointer -> Object 4
Object 4 = struct { Value: 10, Next: Ref(4) }
On import, register(Ref(4), Node) allocates and registers a Node before its fields need to be complete. When Next refers to object 4, it finds that allocation instead of allocating another node. This is why decoding does not require reaching a leaf before creating its parent; a cycle has no such leaf.
7.2 The Traversal and Write Order
State.Save(..., &fn)registers the root function variable as object 1.resolve(pointer, reference)finds an existing object or assigns an ObjectID, queues new content, and fills the reference. It does not immediately copy every descendant.- The work queue encodes each object's fields or elements. New pointers, backing arrays, maps, channels and closure storage can add objects to the queue. Existing references reuse their IDs.
- Type information and reflectx method callbacks are resolved too. Method callbacks can expose more objects/types, so their dependencies are processed before finalizing the tables.
- The writer emits the standard-reflect table, reflectx table, method records when present, then the state type/object records. Object content is emitted with its ID, in ID order.
The main encoder and decoder use reflect.Kind and a small set of explicit runtime-value cases. Ordinary structs do not require generated StateSave methods. Registered types with their own StateSave/StateLoad retain that separate existing path.
7.3 Scalars, Strings and Nil
| Source value | Logical encoding | Destination behavior |
|---|---|---|
true |
Boolean value. | Set a local bool. |
int32(-3), int64(10) |
Signed integer value. | Destination type supplies the width; reject truncation. |
uint16(7), uint64(8) |
Unsigned integer value. | Set the corresponding local unsigned type. |
uintptr(123) |
Integer 123. | It stays 123; do not interpret it as an object pointer. |
float32(1.5), float64(2.5) |
Float value with the corresponding representation. | Set the local float. |
complex64(1+2i), complex128(3+4i) |
Real and imaginary components. | Restore the local complex value. |
"hello" |
String bytes hello. |
Reconstruct a local string, not its host data-pointer header. |
| A nil pointer, slice, map, channel or function | The appropriate nil/empty-reference representation. | Restore nil; nil and an allocated empty container are different states. |
Typed zero values can use the codec's zero-value record. Strings preserve contents, not identity of their backing byte storage. Aliases such as byte and rune use their underlying Go types. Named values are decoded into the destination's resolved type.
An arbitrary unsafe.Pointer value has no default graph-traversal rule: it supplies neither the target's type nor its object extent. Known wrappers such as atomic.Pointer[T] are handled separately using their retained type information.
7.4 Pointers and Structs
Consider the native callback that captures a mutable n := 10. Its compiler-generated closure storage can be viewed as:
struct {
F uintptr // callback PC
X0 *int // &n
}
Object 4: struct
field 0 = callbackPC
field 1 = Ref(5)
Object 5: int
value = 10
encodeStruct iterates fields in order. F is encoded as an integer; X0 goes through pointer resolution and records object 5. The decoder already knows the struct type from the closure layout. It allocates object 4, sets its first field, obtains or allocates object 5, and assigns object 5's local address to the second field. The integer record fills object 5 with 10.
For an ordinary struct, the destination similarly learns its type through the root, pointer, containing field or interface. Inline structs remain inline records; they do not automatically receive separate IDs. An interior pointer such as &node.Value can be represented as a reference to the node plus a field path, preserving that relationship instead of creating an independent integer.
7.5 Arrays and Slices
a := [3]int{10, 20, 30}
s := a[:2]
t := a[:1]
Assuming the graph includes a and both slices:
Object 6 = [3]int{10, 20, 30}
s = Slice{Ref: 6, Length: 2, Capacity: 3}
t = Slice{Ref: 6, Length: 1, Capacity: 3}
The codec follows slice storage through capacity, not only length, so later reslicing observes the saved elements. Both restored slices use the same restored array. Supported interior ranges are references into their containing array rather than copied unrelated arrays.
Small arrays and arrays containing references are encoded element by element. Numeric arrays of at least 64 elements use a bulk byte copy in the current implementation; this also applies to numeric slice backing arrays. []string, []*Node and structs still need recursive processing. The bulk path relies on the same executable's scalar layouts and does not relocate integers that happen to look like addresses.
7.6 Maps and Interfaces
A Go map is exported as logical entries, not its runtime buckets:
n := &Node{Value: 10}
m := map[string]*Node{"left": n, "right": n}
alias := m
m, alias -> Object 7
Object 7 = Map{
"left": Ref(4),
"right": Ref(4),
}
Object 4 = Node{Value: 10, Next: nil}
Import creates one local map and inserts the decoded entries, computing local hashes. Both variables refer to that map; both entries refer to the same local node. Map iteration order is not a serialization identity.
An interface needs its concrete type as well as its value:
var x any = n
-> Interface{Type: *Node, Value: Ref(4)}
var p *Node = nil
var y any = p
-> Interface{Type: *Node, Value: nil}
var z any = nil
-> Interface{Type: nil, Value: nil}
The decoder resolves the concrete type, decodes a value of that type, and assigns it into the interface. It does not copy a host itab. A typed nil interface therefore stays different from a nil interface.
For the Formula example:
source: fs.FS
-> Interface{Type: os.dirFS, Value: "/src"}
-> guest-local os.dirFS value assigned to fs.FS
-> source.Open(...) uses the guest filesystem
Custom structures that keep address-dependent hashes inside ordinary integer-keyed maps are different from Go's map buckets. Restoring their stored hash integers does not recompute the custom hash. The existing typeutil-compatible map handling rebuilds supported x/tools/gogen tables after their key objects are populated; arbitrary hand-written hash tables are not automatically covered.
7.7 Native Functions Without Captures
function value referring to a capture-free native function
PC = functionPC
Env = empty reference
The encoder recognizes supported static function descriptors instead of assuming that a function name without or with .func1 implies captures. The receiver creates PC-only function storage. No host heap address or capture object is needed.
The linker placeholder runtime.unreachableMethod also has a supported PC-only representation. Transferring that placeholder does not make an unavailable method callable.
7.8 Ordinary Native Closures
n := 10
callback := func() {
n++
}
Function record
PC = callbackPC
Env -> Object 4: complete closure storage
F = callbackPC
X0 -> Object 5: int(10)
The layout is the compiler's full closure struct, including F uintptr at offset zero. Discovery follows the enclosing native function's allocation instructions, associating a closure PC with the type passed to runtime.newobject. The corresponding type descriptor is in the mapped ELF even when the compiler's noalg closure type is absent from ordinary typelinks. The implementation validates the PC field, size and field offsets; it does not use DWARF or scan the entire heap.
The address extraction has two levels:
function variable -> funcval storage { PC, captures... }
^
obj.Addr()
storage := *(*unsafe.Pointer)(obj.Addr().UnsafePointer())
view := reflect.NewAt(layout, storage)
NewAt views existing storage; it does not allocate, skip the PC or discover a type. The supplied layout already includes PC and captures.
On import, allocate that typed struct, register its ObjectID before filling references, and make the restored function value point to it. Local Go GC sees the struct's typed pointer fields. The PC is currently stored twice: in the function record for lookup and in the struct's F field for execution. Decode checks they agree. This documents the current representation; it does not introduce a format change to remove the redundancy.
7.9 Bound Methods and Reflect Method Values
For a native method value such as fn := driver.Lookup, the wrapper binds a receiver:
Native bound method, commonly named (*Driver).Lookup-fm
PC = boundMethodPC
Env -> { F: boundMethodPC, R: Ref(driverObject) }
The receiver's static type is resolved from executable method/type metadata, and the receiver value uses normal object traversal. A value receiver is stored by value; a pointer receiver refers to its object. In contrast, the method expression (*Driver).Lookup takes its receiver as an explicit argument and does not capture that receiver.
A function obtained through reflect.ValueOf(driver).MethodByName("Lookup") uses a different wrapper:
Reflect method function
PC = reflect.methodValueCall
Env -> {
method: index within the receiver's method set,
receiver: represented reflect.Value(driver),
}
The method index and receiver are transferred. After decoding the graph, the receiver's Method(index) constructs the local method wrapper and call layout. The codec does not transplant its host stack map or argument-layout cache.
7.10 MakeFunc: Rebuild the Adapter, Transfer Its Callback
n := 10
callback := func(args []reflect.Value) []reflect.Value {
n++
return []reflect.Value{reflect.ValueOf(n)}
}
fn := reflect.MakeFunc(reflect.TypeFor[func() int](), callback)
MakeFunc supplies a native adapter between the public signature func() int and callback([]reflect.Value) []reflect.Value. Its runtime object includes a stub PC, argument/GC layout, the internal signature and the callback function value.
The encoded graph is:
MakeFunc record
PC = reflect.makeFuncStub
Type = func() int
Env -> Object 2: callback function record
PC = callbackPC
Env -> Object 3: complete callback closure storage
F = callbackPC
X0 -> Object 4: int(10)
Here the outer Env is a reference to the callback variable, not a copy of the entire makeFuncImpl. It is created by:
es.resolve(reflect.ValueOf(&impl.fn), &f.Env)
&impl.fn supplies the address of that function variable. resolve assigns or reuses object 2, stores its ID in f.Env.Root, and queues its content. Encoding object 2 subsequently enters the same encodeFunction used for ordinary closures, which discovers objects 3 and 4. The outer makeFuncStub PC and inner callbackPC select different code; only the ordinary callback's PC versus its struct field F are redundant.
Decoding proceeds as follows:
- Resolve the saved internal signature.
register(&f.Env, callbackType)obtains a writable function variable for object 2, allocating it if needed. - Create
reflect.MakeFunc(signature, nil)and retain its association with that callback variable. Reflect builds the local adapter layout. - Decode object 2 using ordinary function handling, create object 3, and connect its captured pointer to object 4 containing 10.
- After the graph is decoded, assign the restored callback into the new adapter's
fnfield. The outward function type is adapted when necessary, because reflectx/ixgo can expose a different outer signature without changing the original internal signature. - Calling the result enters the stub, invokes the restored callback, increments the local
nand returns 11.
Publishing the adapter before installing its callback allows the captured graph to refer back to that adapter. On return export, the codec associates the newly created adapter's callback slot with the imported callback ObjectID, so the new slot address does not invent a second logical object.
7.11 reflect.Type and Dynamic Type Descriptions
A reflect.Type value is metadata. The codec saves a type reference rather than following *reflect.rtype as an ordinary application struct:
Input: reflect.TypeOf((*Node)(nil))
Value: ReflectType{Type: standard-table entry 12}
Table: entry 12 -> static executable type location for *Node
Output: the receiving runtime's reflect.Type for *Node
For static descriptors, the table records executable module/offset identity and resolves it through the receiver's local type index. For dynamic standard-reflect types, it records constructor inputs. Export enumerates the supported ptrMap, lookupCache, funcLookupCache and structLookupCache entries, then follows type dependencies. It does not copy the memory occupied by those caches. A function type contains a signature, not a PC or a callback.
For example:
Input type:
reflect.StructOf([]reflect.StructField{
{Name: "Count", Type: reflect.TypeFor[int]()},
})
Type table:
T1 = builtin int
T2 = struct { Count: T1 }
Open:
resolve T1
construct StructOf([{Name: "Count", Type: local T1}])
Resolve(T2) returns the resulting local reflect.Type
| Type kind | Description/example carried by the table | Local reconstruction |
|---|---|---|
| Builtin scalar | int, string, bool, etc. |
Select the local builtin type. |
| Static named or interface type | *Node, fs.FS: executable location. |
Resolve the existing descriptor and methods. |
| Pointer | *T: element type ID. |
reflect.PointerTo(T). |
| Slice | []T: element type ID. |
reflect.SliceOf(T). |
| Array | [3]T: length 3 and element type ID. |
reflect.ArrayOf(3, T). |
| Map | map[K]V: key and element type IDs. |
reflect.MapOf(K, V). |
| Channel | <-chan T: direction and element type ID. |
reflect.ChanOf(direction, T). |
| Function | func(int) string: input/output type IDs and variadic flag. |
reflect.FuncOf(...); no function value is created. |
| Struct | struct{ Count int }: ordered fields, names, types, package paths, tags and embedding flags. |
reflect.StructOf(fields). |
| Dynamic named/interface types and extended methods | For example an interpreted named type with methods. | The reflectx provider described below. |
State type references connect these tables to interface values, explicit type values and other records that need runtime types. An ObjectID is never inferred from a TypeID. The type tables may contain metadata outside the immediate captured graph, so on-demand object traversal does not imply a minimal global type-cache export.
7.12 reflect.Value
A reflect.Value is a wrapper around a represented value. The codec exports that represented type and value, plus addressability, instead of copying its internal typ/ptr/flag words.
n := 10
v := reflect.ValueOf(&n).Elem()
v -> ReflectedValue{
Addressable: true,
Type: *int,
Value: Ref(4),
}
Object 4 = int(10)
decode:
resolve *int and Ref(4)
reconstruct the pointer Value
take Elem() -> addressable Value backed by object 4
Thus v.SetInt(11) updates the same restored integer referenced elsewhere. For reflect.ValueOf(10), the record is non-addressable, with type int and inline value 10. A zero reflect.Value{} has an explicit invalid-type/value representation.
If the represented value is a function, it enters the function paths above; if it is a reflect.Type, it enters the type-value path. Values with restricted access from unexported fields are rejected rather than importing permission flags.
7.13 reflectx/xtype Types and Methods
Standard reflect constructors cannot express every named type, interface or method set created by ixgo through reflectx. The second provider exports supported dynamic definitions and their relationships, including names/package paths, struct fields, underlying types, signatures and method definitions. An encountered xtype.Type uses the corresponding type reference as well.
A schematic interpreted type illustrates the two parts:
Type table:
T20 = named Counter, underlying struct { Value int }
method Add: signature func(int), pointer receiver, function index M1
Method functions:
M1 -> callback function record
PC = native implementation callback PC
Env -> callback's reachable objects
Object graph:
Object 8 = Counter{Value: 10}, decoded using local T20
The method function index selects an entry in the method-function list; it is not an application ObjectID or a raw reflectx icall address. State handles callback functions with its function encoder. The receiving provider creates local type identities and method storage, installs the reconstructed callbacks, and rebuilds local method bindings before interface values need them. Recursive named types reserve their identities before resolving dependencies such as *Counter.
Export reads the supported standard caches and reflectx.Default caches; it is not an enumeration of arbitrary unregistered heap objects. Retained type and method identities are carried through return export so original host types can be reused. Type construction and reflectx Context resets must not race with transfer; codec locks do not make unrelated external cache mutation an atomic snapshot.
7.14 Channels
ch := make(chan *Node, 2)
ch <- n
close(ch)
alias := ch
ch, alias -> Channel{Capacity: 2, Ref: 9}
Object 9 = ChannelData{Closed: true, Values: [Ref(4)]}
Object 4 = Node{Value: 10, Next: nil}
The codec snapshots the FIFO contents without receiving from the source channel. Import creates a new local channel, restores queue entries and closes it if required. Aliases in the new graph share that channel. It does not migrate waiting goroutines or the runtime channel lock.
Channels with waiters, a detected runtime timer attachment, synctest association or a changing queue are rejected. A channel returned to the host also has independent-snapshot semantics: the codec does not enqueue into or close the original host channel. External aliases outside the transferred graph do not become a communication channel to the guest.
7.15 Atomic and Synchronization Values
| Source example | Logical contents transferred | Restored state |
|---|---|---|
atomic.Int64 containing 10 |
Value 10. | Local atomic wrapper containing 10. |
atomic.Value storing *Node |
Its logical interface value and Ref(Node). |
Local stored value referring to the restored node. |
atomic.Pointer[Node] storing n |
Typed *Node reference. |
Pointer to the local node, not the host address. |
sync.Map with "n" -> n |
Logical key/value entries. | Fresh/cleared local map populated through Store; no hash-trie copy. |
sync.Pool{New: callback} |
New and its closure graph. |
Empty pool with restored New; cached pool entries are discarded. |
sync.Cond{L: mutex} |
The locker interface and its referenced object. | Restored locker; no waiters or notification queue. A mutex locker is reset by its own sync rule. |
| Mutex, RWMutex, Once, WaitGroup and other reset sync wrappers | Empty sync record. | Zero state: no held lock, waiters, Once done flag or WaitGroup count. |
For atomic.Pointer[T], the wrapper retains T in its Go type. The codec uses that information to view its internal pointer slot as *T and apply ordinary pointer relocation. This does not justify treating arbitrary unsafe.Pointer values as typed pointers. All these snapshots require the source graph to be quiescent; preserving an atomic value does not migrate a concurrent operation.
7.16 Native Registrations and Process Resources
A registered *ixgo.Package is encoded as its package path and resolved through the receiver's native registrations after package initialization. The codec does not recursively transfer every static package entry.
*ixgo.Package -> registered package path
-> receiving runtime's ixgo.LookupPackage(path)
os.Stdout -> standard-stream identity
-> receiving runtime's os.Stdout
os.DirFS("/src") -> os.dirFS("/src")
-> methods open files through guest syscalls
other *os.File -> unsupported live resource
Standard streams are explicit exceptions matching Sentry's descriptor imports. Arbitrary open files, sockets reached through poll state, OS processes, timers and cancellation contexts are rejected when their unsupported resource state is encountered. A copied FD number would not create the associated guest resource. General resource migration is not implied by the same-ELF rule.
7.17 How the ixgo Formula Uses These Rules
For ixgo v1.1.6, an interpreted callable is built using reflect.MakeFunc. The native callback invokes interp.callFunctionByReflect(..., pfn, typ, args, env) and captures the interpreter, interpreted function, signature and interpreted captures:
loaded.OnBuild
-> possible native LLAR lifetime wrapper
-> MakeFunc
signature = func(*formula.Context)
callback PC = ixgo native wrapper code in the ELF
callback storage
interp -> reachable interpreter state
pfn -> reachable interpreted function/program data
typ -> reflect.Type reference
env -> []any of interpreted captures
-> this -> generated Readfile instance
The codec does not search the ELF for machine code generated from Readfile_llar.gox: the Formula is interpreted. Its program/function objects are data, reached through the callback, while the interpreter implementation is native code already in the executable. The current state path transfers these reachable objects; it does not regenerate the Formula from saved source or replay its initialization.
This combines the earlier examples: a MakeFunc adapter, a native callback closure, pointers/structs, an []any, interfaces, type references and dynamic methods. Understanding each layer prevents the misleading claim that migrating only the visible []any captures is sufficient for today's implementation.
7.18 Decode and Host Writeback
Import restores type information first, then obtains typed local storage for object IDs as references are encountered. Records that arrive before their objects are requested can be deferred; existing registered storage is reused. Struct fields and container elements are populated, ordinary closure PCs are checked, and MakeFunc/method wrappers are completed before restored functions are used.
host: ObjectID 4 -> original node H
guest: ObjectID 4 -> local node G
guest changes G.Value from 10 to 11
return image: Object 4 = Node{Value: 11, ...}
host writeback: update original H.Value to 11
The guest's State retains imported IDs for return export, including objects later detached from the root; new objects get new IDs. The host's State retains the original ID-to-object association. Host import first checks the graph in independent storage, then restores into retained objects. Channels have the independent-snapshot exception described above.
This is not an atomic transaction. Custom restore hooks can run during validation and writeback, and a hook that fails only during writeback can leave changes partially applied. External filesystem/process effects are not rolled back.
8. LLAR Integration
8.1 Keep the Call, Select Its Environment
Current calling form:
build := loaded.OnBuild
build(ctx)
Proposed calling form, using the existing sandbox API:
Create the configured Sandbox at its owning controller's lifetime boundary and reuse it for calls with that configuration. The example shows one call; the deferred Close belongs to the enclosing owner, not a per-build loop.
build := loaded.OnBuild
s := sandbox.Sandbox{
Library: sentryLibrary,
Mounts: []sandbox.Mount{
{Type: "bind", Source: rootDir, Target: "/", Options: []string{"ro"}},
{Type: "bind", Source: sourceDir, Target: "/src", Options: []string{"ro"}},
{Type: "bind", Source: outputDir, Target: "/out", Options: []string{"rw"}},
{Type: "tmpfs", Target: "/tmp", Options: []string{"mode=1777"}},
{Type: "proc", Target: "/proc"},
},
Env: []string{"PATH=/usr/bin:/bin", "LANG=C", "HOME=/tmp"},
Inspect: func(call *sandbox.Syscall) {
if call.Name == "read" {
log.Printf("read(fd=%d, addr=%#x, count=%d)",
call.Args[0], call.Args[1], call.Args[2])
}
},
}
defer s.Close()
if err := s.Run(func() { build(ctx) }); err != nil {
return err
}
if len(ctx.Errs) != 0 {
return errors.Join(ctx.Errs...)
}
metadata := ctx.Out.Metadata() // "hello", in the original host process.
The path variables are inputs prepared by LLAR, not new sandbox options. rootDir is a selected runtime/toolchain tree containing the same LLAR executable at its original absolute path, its loader and libraries, and the mountpoint directories. ctx must use guest paths such as /src and /out; the transfer layer does not rewrite strings. The current guest runs as UID/GID 1000, so the output directory must permit its writes.
| Setting | Existing contract | LLAR implication |
|---|---|---|
Mounts omitted or empty |
Read-only host / plus guest /proc. |
Convenient for tests; read-only host access is not a secret-hiding filesystem policy. |
Nonempty Mounts |
Replaces all defaults; the first mount supplies /. |
LLAR explicitly selects visible source, tools, dependencies and writable outputs. |
Env == nil |
Inherits the current host environment on each Run. |
Preserves normal tool lookup; does not establish reproducibility. |
Env: []string{} |
Empty guest environment. | No inherited PATH or other variables. |
Nonempty Env |
Complete replacement, installed before guest runtime/package initialization. | LLAR supplies the selected build environment. |
getenv normally reads process memory, so it will not appear as a syscall in Inspect. Environment selection must happen at guest creation. Previously captured strings or cached settings are still part of the transferred graph; changing Env does not rewrite them.
8.2 Shared Transport
Per-Run memfd, imported into the guest as fd 3
+--------------------+---------------------------+
| uint64 input size | input state image |
+--------------------+---------------------------+
| uint64 result size | result state image |
+--------------------+---------------------------+
Each size includes its own 8-byte header.
Each state image contains type tables and encoded objects/references.
The result starts immediately after the input image.
The receiver reads the length before mapping; the memfd grows when necessary. The host retains the result offset itself. This transport is separate from systrap's shared ThreadContext and from syscall temporary memory.
The size headers are little-endian uint64 values. Reads check the complete extent against the file length and addressable mapping size, then copy the payload into local bytes before decoding. Snapshot mappings request read/write or read access, not executable access; this is not a claim that every hostile-guest route to an executable mapping has been prohibited.
9. Syscall Inspection
From the sandbox README:
Host runtime c-shared Sentry runtime
Run(fn)
closure + reachable values
|
+-- memfd, startup addresses ---> CreateProcess(same ELF), guest fd 3
|
install main -> guestEntry branch
|
Start -> Go runtime and package init
|
guestEntry -> reconstruct captures -> fn()
|
syscall -> seccomp/SIGSYS
|
sysmsg -> Context.Switch returns
|
Inspect(Name, Number, Args) <-------------+
| |
+-- MMap(addr, size) --------------> MMap + Pin; CopyIn only if addr != 0
| <------ Data alias, guest Addr ----+
+-- edit Data --------------------> same temporary pages
+-- optional Number / Args edits --> syscall registers
| |
+-- callback returns --------------> Sentry syscall dispatch
+-- release temporary guest memory
|
next Switch -> guest continues
|
fn returns -> export changed graph
|
wait for guest exit <--------------------+
copy output out of shared memory
validate types/code/references
update original captured objects
Run returns
9.1 Follow One Read Through the Interceptor
Example os.ReadFile sequence, omitting setup and size queries. Fds, addresses and context IDs are illustrative:
openat(..., "/src/hello.txt", O_RDONLY, ...) -> guest fd 9
read(9, buffer, 512) -> 5 bytes: hello
read(9, buffer, ...) -> 0: EOF
close(9) -> 0
ARM64 registers at the first read:
X8 = 63 syscall number: read
X0 = 9 guest file descriptor
X1 = 0x700000 guest buffer address
X2 = 512 requested byte count
- The Task waits in
Context.Switch. seccompTRAPdeliversSIGSYS; sysmsg saves signal-frame registers to sharedThreadContextand publishes the state. systrap observes it or wakes through futex. - The guest context pauses; workers may run others.
Switchcopies registers to the Task and returns a syscall event. Before the read executes, the wrapper calls LLAR'sInspectsynchronously through C into the host runtime. - LLAR sees
Name="read",Args={9, 0x700000, 512, ...}. Return applies register edits and resumes the Task loop.doSyscalluses VFS for fd 9, copieshelloto guest memory and sets return value 5. - The next
Switchqueues context 7 and waits. A worker restores it, resuming the guest withX0=5.
Switch returns one event, not the completed Run. Runtime/child syscalls and gVisor's optimized syscall sites reach the same inspection boundary. Faults, interruptions, queues and futex handoff retain normal Sentry handling.
9.2 Read and Replace Syscall Inputs
The interceptor parses the name, number and six raw arguments passed through C, optionally using a third-party strace formatter. LLAR receives no Sentry-specific Go types or structured codec.
| Inspector operation | Meaning |
|---|---|
Read call.Name, call.Number, call.Args |
Inspect the pending entry registers. |
call.MMap(addr, size) |
Allocate temporary guest pages and initialize them from guest bytes at addr. Return a host view and their guest address. |
call.MMap(0, size) or call.Malloc(size) |
Allocate new zeroed temporary guest pages. |
Edit memory.Data |
Change those temporary pages immediately. The original source buffer is unchanged. |
Assign call.Args[i] = memory.Addr |
Explicitly substitute the guest pointer used by Sentry. Nested pointer fields require the same explicit treatment. |
Return from Inspect |
Submit register edits; no separate Commit call. |
Example: replace an openat pathname
Original guest pathname: /src/hello.txt
Allocate temporary guest bytes for: /src/other.txt + terminating NUL
Write through Memory.Data
Set pathname argument to Memory.Addr
Return -> Sentry opens /src/other.txt
The original pathname bytes remain /src/hello.txt.
Data and Addr expose the same temporary pages at different host/guest addresses. Inject only Addr, never host Go/C pointers. Source initialization copies inside Sentry; it is neither COW nor a host staging-buffer commit.
The host view expires when the callback returns. Temporary guest mappings last until synchronous completion; restarted/skipped calls may retain them until Run finishes. Guest threads can still unmap/remap these addresses; they are not protected or persistent allocations. Outputs are not copied back to original buffers.
10. Return to the Original Caller
Guest Formula
os.ReadFile returns "hello"
ctx.SetMetadata("hello")
build(ctx) returns
outer fn returns
|
Guest entry
State.Save: preserve imported object IDs, include changed/new values
write result image to fd 3
exit successfully
|
Sentry
wait for this Run's tasks; release their resources, retain the Kernel
return through C bridge
|
Original host
read result header and copy image into host-owned bytes
State.Load: decode/check, then reuse retained objects by object ID
original ctx.Out.Metadata() becomes "hello"
Sandbox.Run returns
caller continues in the same host process
| State | Before Run |
During the Formula | After successful writeback |
|---|---|---|---|
| Original host context | metadata="" |
Still metadata=""; exclusively owned |
metadata="hello" |
| Guest context | Not created | Independent object, updated to "hello" |
Guest exits |
| Host inspector | Configured | Executes syscall callbacks in the host runtime | Remains host-owned |
| Host continuation | Before Run |
Waiting at the function boundary | Next Go statement after Run |
The host retains the original ID-to-object association, including objects the guest later unlinks from the root. Returning a changed reference does not renumber the old object. Guest allocations become local host allocations on import. There is no kernel.SaveTo, CRIU restore, native-stack import or second execution of OnBuild.
Structural decoding is checked before overwriting retained objects. This is not a general transaction: custom restore hooks can have side effects or fail during writeback, and filesystem/child-process side effects are not rolled back.
11. Build Records and Safety Boundaries
| Available now | What it does not prove |
|---|---|
| Selected mounts and explicit environment | Every build input is deterministic; clocks, randomness, concurrent file changes and captured cached settings still matter. |
Inspect(read, fd=9, count=512) |
That read succeeded, which bytes it returned, or the authoritative identity of fd 9. |
| Entry interception for guest runtime, Formula and children | A separate Formula-only trace scope or a syscall completion record. |
| Sentry execution and typed result restoration | That arbitrary guest-controlled return images are safe to import into the host. That boundary still needs security review. |
The five-byte result in the walkthrough is the illustrated file operation, not data supplied by the current entry hook. LLAR can record attempted operations now; reliable completion/file-identity records require a separately designed observation point. An inspector panic is not an allow/deny API.
12. Error Handling
| Condition | Behavior |
|---|---|
| Missing library, unsupported native layout, unsupported captured resource or startup error | Return an error; do not silently execute the callback on the host. |
| Invalid mounts or an environment entry containing NUL | Reject startup. |
| Guest file access fails | The Formula receives the normal error and can add it to ctx.Errs. A successful Run does not itself mean the build succeeded. |
| Guest panic, unsuccessful exit, inspector failure or missing result image | Report failure before host graph import. External side effects may already exist. |
| Independent concurrent host calls | Fresh guests share the Kernel; captured graphs must remain independent and quiescent during their transfers. |
Run after Close |
Return an error without starting another guest. |
| Invalid returned state | Return a decoding error; do not rerun the callback. Custom-hook writeback limitations still apply. |
13. Compatibility
The LLAR adoption preserves the existing classfile syntax and callback signature. No Guest() call is exposed. The implementation described here targets Linux AMD64/ARM64, Go 1.26.6, cgo, the same non-PIE ELF with symbols, ixgo v1.1.6 and reflectx v1.7.8. Required link settings include -checklinkname=0; native layout discovery needs ELF symbols but not DWARF. The host must start with GLIBC_TUNABLES=glibc.pthread.rseq=0.
The reusable-Kernel C ABI has CreateSandbox, RunSandbox and CloseSandbox. It is incompatible with released backends through sentry/v0.5.0. Build the host and library from matching source revisions; the Go module version does not negotiate the ABI of a library loaded through dlopen.
The value/resource rules in section 7 are the compatibility contract, including reset synchronization state, independent channel snapshots and rejected live resources. Captures must remain exclusively owned during a call, relevant type construction must be quiescent during transfer, and guest work must finish before the closure returns. Native guest package initialization runs again. Reusing the Kernel does not reuse a previous guest's initialized interpreter or type tables.
14. Validation
| Case | Required assertion |
|---|---|
| Classfile transformation | The documented source generates the shown instance fields, this accesses and callback registration with the pinned XGo version. |
| Read-file Formula | Metadata reaches "hello" through guest execution; the original host continues and the callback runs once. |
| Object graph | Shared pointers, interior references, cycles and detached-but-retained objects preserve the documented identities. |
| Scalars and containers | Nil versus empty values, slice length/capacity, aliases, map keys and bulk numeric arrays retain their semantics. |
| Function variants | Capture-free functions, native closures, bound methods, reflect method values and MakeFunc use their documented layouts and restore callable results. |
| Reflection and ixgo | Type references, addressable Values, dynamic methods, native package registrations and interpreted callbacks survive the round trip. |
| Synchronization and channels | Preserve logical values, reset synchronization state, retain Pool.New/Cond.L, and enforce independent channel semantics. |
| Reusable Kernel | Sequential and independent concurrent Runs use fresh process resources; closing rejects new calls and terminates active ones without leaking resources. |
| Filesystem and environment | Each Run sees its configured mounts/environment; guest mutations do not alter host environment or another guest's private state. |
| Syscall entry and memory | Inspection completes before dispatch; only guest addresses are injected; temporary edits leave original source bytes unchanged. |
| Failures | Unsupported resources, missing native layouts and malformed results stay visible; no host execution fallback or automatic retry. |
Existing native state, ixgo and LLAR integration tests provide the regression suites; these rows describe the required coverage rather than claiming that every upstream test passes. Both Linux architectures need execution evidence for runtime changes. This documentation update generated the new classfile example with the pinned xgo go and compiled its resulting Go package; it did not rerun the Sentry or ixgo suites and is not a security audit.
15. Decisions and Remaining Work
| Area | Decision |
|---|---|
| LLAR boundary | Keep orchestration in the host and execute the selected callback in a guest, using the existing Formula calling shape. |
| Code and data | Reuse the same ELF for native code; transfer supported reachable values with object/type references. |
| Sentry ownership | One Kernel per reusable Sandbox, one fresh process and state image per Run. |
| Interception | LLAR owns inspection after the underlying Context.Switch returns and before Sentry dispatch. |
| Shared memory | Carry state images and temporary syscall pages; do not create a shared Go heap or inject host pointers. |
| Deferred work | Formula initialization placement, authoritative syscall-completion records, hostile returned-image review, unsupported resources and guest process pooling. |
The CRIU round trip would need to reconcile divergent process states or keep the original host stopped during the build. A purpose-built RPC worker remains viable if LLAR chooses a new explicit remote API; preserving the current call still requires recursive state encoding, for which classfile captures provide the roots. Fixed host/guest heap addresses would couple allocation and GC ownership, while object IDs allow independent heaps. Interception of selected helpers alone cannot observe direct file operations and child tools, so enforcement remains at Sentry's operating-system boundary.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with formula/demo/sandbox/Readfile_llar.gox and the generated Go described in the issue, then run xgo go ./formula/demo/sandbox and go test ./formula/demo/sandbox. Read the loaded.OnBuild entry point and the sandbox.Run boundary before assessing the closure and object-state transfer. Done should include a concrete implementation plan and validation for isolated Formula execution while preserving context updates.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- backend, build-system, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100