Skip to content

Add a Go SDK (cgo bindings over libsandlock_ffi)#83

Merged
congwang-mk merged 4 commits into
multikernel:mainfrom
solarhell:add-go-sdk
Jun 2, 2026
Merged

Add a Go SDK (cgo bindings over libsandlock_ffi)#83
congwang-mk merged 4 commits into
multikernel:mainfrom
solarhell:add-go-sdk

Conversation

@solarhell
Copy link
Copy Markdown
Contributor

Closes the Go-support question in #76.

Per @congwang-mk's guidance there (a real cgo/FFI SDK, placed under sandlock/go,
following the existing FFI + Python binding), this adds a Linux-only Go SDK that
binds the sandlock C ABI (libsandlock_ffi) via cgo and mirrors the Python
SDK's Sandbox surface.

What's included

  • Config → policy: Sandbox maps every current builder field (filesystem,
    network, HTTP ACL, resource limits, syscall filtering, determinism,
    environment, COW branch handling, uid, …) to the FFI builder. fs_isolation
    is intentionally absent, tracking its removal in the core.
  • Execution: Run (captured), RunInteractive (inherited stdio), DryRun
    (with the filesystem Changes list), all taking a context.Context (a
    deadline maps to the FFI wait timeout).
  • Lifecycle: Spawn returns a *Process with Pid/Wait/Pause/
    Resume/Kill/Ports/Close.
  • In-process confinement: Confine applies Landlock rules to the current
    process — something the CLI fundamentally cannot do.
  • Platform: LandlockABIVersion, MinLandlockABI, SyscallNr.
  • Tests: pure cross-platform parsing helpers in internal/policy with unit
    tests, plus Linux integration tests that t.Skip when the kernel Landlock
    ABI is below the minimum.
  • CI: a go job that builds libsandlock_ffi and runs go vet + go test
    on the ubuntu-latest and ubuntu-24.04-arm runners.

Verification

Built and tested in a Linux container (Landlock ABI v8): cargo build,
go build ./..., go vet ./..., and go test ./... all pass, exercising a
real sandboxed Run/DryRun. Readable paths in the tests are filtered through
os.Stat so the arm64 runner (no /lib64) stays green.

Scope / follow-ups

This first PR covers the static policy surface plus Confine. Deliberately left
for follow-ups: dynamic policy_fn callbacks, custom seccomp handlers,
pipeline/gather, COW fork/reduce, and checkpoint.

One upstream note for policy_fn: the callback type
sandlock_policy_fn_t carries no user_data. Python works because ctypes
synthesises a distinct C function pointer per closure; Go's //export yields a
single fixed pointer, so it cannot route the callback to a per-Sandbox closure
without a void *user_data parameter on sandlock_sandbox_builder_policy_fn
(passed back into the callback). Happy to send that as a small separate PR to
unblock the Go policy_fn binding if you're open to it.

Open questions

  • Module path: I used github.com/multikernel/sandlock/go (subdir + go/vX.Y.Z
    tags). Happy to switch to a separate sandlock-go repo if you prefer.
  • The default cgo link flags resolve the library at ../target/release; let me
    know if you'd rather standardise on an installed-library path.

A Linux-only Go SDK that binds the sandlock C ABI via cgo, mirroring the
Python SDK's Sandbox surface. Covers the static policy configuration plus
in-process Confine (which the CLI cannot do):

- Sandbox config -> native policy for every current builder field
- Run / RunInteractive / DryRun with captured Result and Changes
- Spawn + Process lifecycle (Pid/Wait/Pause/Resume/Kill/Ports/Close)
- Confine, LandlockABIVersion, MinLandlockABI, SyscallNr
- pure, cross-platform parsing helpers (internal/policy) with unit tests
- linux integration tests that skip when the kernel ABI is too old
- a CI job that builds libsandlock_ffi and runs go vet + go test

Dynamic policy_fn callbacks, custom seccomp handlers, pipeline/gather,
fork/reduce, and checkpoint are intentionally left as follow-ups. policy_fn
in particular needs a void* user_data parameter added to
sandlock_sandbox_builder_policy_fn before Go can route the callback to a
per-Sandbox closure.
Copy link
Copy Markdown
Contributor

@congwang-mk congwang-mk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work! Just two issues below:

Comment thread go/sandlock_linux.go Outdated
if p.h == nil {
return nil, ErrNotRunning
}
r := C.sandlock_handle_wait(p.h)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Process mutex is held across the blocking native wait, so Kill can't interrupt Wait?

Comment thread go/sandlock_linux.go
mu sync.Mutex
h unsafe.Pointer
pid int
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No finalizer on Process, which leaks handle + orphaned child if dropped without Close/Wait?

@congwang-mk
Copy link
Copy Markdown
Contributor

For the open questions, the current code is fine as it is.

…lizer

Address review feedback on the Go SDK Process lifecycle:

- Wait reserved the mutex for the whole native wait, so Pause/Resume/Kill
  (and Close) blocked behind it and could not interrupt a blocked Wait. Wait
  now takes ownership via a 'waiting' flag, releases the mutex across the
  blocking sandlock_handle_wait, and reacquires it to free the handle. The
  signal helpers act on the process group by PID and touch no handle state, so
  Kill now interrupts Wait. The FFI handle is not safe for concurrent access,
  so Ports defers (reports empty) and Close defers the free to Wait while a
  wait is in flight.

- A Process dropped without Wait/Close leaked the handle and orphaned the
  child. Spawn now installs a runtime finalizer that kills the group and frees
  the handle; Wait and Close clear it once they have done so.

Adds TestProcessKillInterruptsWait. Verified green in a Linux container
(Landlock ABI v8): cargo build, go vet/build/test, gofmt.
@solarhell
Copy link
Copy Markdown
Contributor Author

Thanks, both are real. Fixed in 036fd89:

  • Mutex held across the wait. Wait now claims the handle via a waiting
    flag and drops the mutex for the blocking sandlock_handle_wait, then
    reacquires it to free the handle. Pause/Resume/Kill only signal the
    process group by PID and never touch the handle, so they run while a Wait
    is in flight — that's what lets Kill interrupt it. Since the FFI handle
    isn't safe for concurrent access (wait borrows it &mut), Ports reports
    empty and Close defers the actual free to the in-flight Wait rather than
    aliasing the handle. Added TestProcessKillInterruptsWait to cover it.

  • No finalizer. Spawn now installs a runtime.SetFinalizer that kills
    the group and frees the handle if a Process is dropped without
    Wait/Close; both clear the finalizer once they've cleaned up.

Verified green in a Linux container (Landlock ABI v8): cargo build, go
vet/build/test, gofmt.

Comment thread go/sandlock_linux.go
int sandlock_landlock_abi_version(void);
int sandlock_min_landlock_abi(void);
int64_t sandlock_syscall_nr(const char* name);
*/
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fixed the sandlock.h generation (PR #87), you can just use the existing sandlock.h:

  /*
  #cgo CFLAGS: -I${SRCDIR}/../crates/sandlock-ffi/include
  #include "sandlock.h"
  */
  import "C"

solarhell added 2 commits June 2, 2026 08:23
PR #87 added a cbindgen-generated header. Include it from the cgo preamble
(-I crates/sandlock-ffi/include) and drop the ~110 hand-maintained prototypes,
so the bindings track the FFI automatically and can't drift.

The generated header types replace the void*/unsigned char placeholders: the
builder, policy, handle and result become their named opaque structs, bool
setters/getters use C.bool, and the byte-length and change-index params use
uintptr_t. Internal pointers are typed accordingly; the public API is
unchanged.

Verified green in a Linux container against the merged main: cargo build,
go vet/build/test, gofmt.
@solarhell
Copy link
Copy Markdown
Contributor Author

Done in e3c7e58 — merged latest main and switched the cgo preamble to the
generated header:

#cgo CFLAGS: -I${SRCDIR}/../crates/sandlock-ffi/include
#include "sandlock.h"

That drops the ~110 hand-written prototypes, so the bindings can't drift from
the FFI anymore. The generated types also let me replace the void* /
unsigned char placeholders with the named opaque structs, C.bool for the
bool setters/getters, and uintptr_t for the byte-length and change-index
params. Public Go API is unchanged.

Verified green against the merged main in a Linux container (Landlock ABI
v8): cargo build, go vet/build/test, gofmt. Thanks for fixing the header
generation!

@congwang-mk congwang-mk merged commit eea51c8 into multikernel:main Jun 2, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants