Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,34 @@ jobs:
- name: Run Python tests
working-directory: python
run: pytest tests/ -v

go:
name: Go SDK (${{ matrix.runner }})
runs-on: ${{ matrix.runner }}
needs: rust
strategy:
fail-fast: false
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@stable

- name: Rust cache
uses: Swatinem/rust-cache@v2

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"

- name: Build FFI library
run: cargo build --release -p sandlock-ffi

- name: Vet and test
working-directory: go
run: |
go vet ./...
go test ./... -v
149 changes: 149 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# sandlock Go SDK

Go bindings for [sandlock](https://github.com/multikernel/sandlock), a
lightweight Linux process sandbox built on Landlock, seccomp-bpf, and seccomp
user notification. No root, no Docker, no namespaces.

The bindings bind the sandlock C ABI (`libsandlock_ffi`) via cgo, mirroring the
Python SDK's `Sandbox` surface. **Linux only**; the runtime requires Linux
6.12+ (Landlock ABI v6).

```go
import sandlock "github.com/multikernel/sandlock/go"
```

## Building

cgo links against `libsandlock_ffi`, produced by the Rust workspace. The
default link flags resolve the library relative to this package
(`../target/release`), so build from a checkout of the sandlock repository:

```bash
cargo build --release # writes target/release/libsandlock_ffi.so
cd go && go test ./...
```

To use the SDK from another module, point cgo at an installed library, e.g.:

```bash
CGO_LDFLAGS="-L/usr/local/lib -Wl,-rpath,/usr/local/lib" go build
```

## Quick start

```go
package main

import (
"context"
"fmt"
"log"

sandlock "github.com/multikernel/sandlock/go"
)

func main() {
sb := &sandlock.Sandbox{
FSReadable: []string{"/usr", "/lib", "/lib64", "/bin", "/etc"},
FSWritable: []string{"/tmp"},
}
res, err := sb.Run(context.Background(), "echo", "hello")
if err != nil {
log.Fatal(err)
}
fmt.Printf("exit=%d: %s", res.ExitCode, res.Stdout) // exit=0: hello
}
```

## API

### Sandbox

`Sandbox` is a plain configuration struct; every field is optional and an unset
field means "no restriction" unless noted. sandlock's default syscall blocklist
is always applied. A `Sandbox` carries no runtime state, so it is safe to reuse
and share across goroutines — `Run`, `RunInteractive`, and `DryRun` build a
fresh native policy on each call.

| Group | Fields |
|---|---|
| Filesystem | `FSReadable`, `FSWritable`, `FSDenied`, `Workdir`, `Cwd`, `Chroot`, `FSMount` |
| Network | `NetAllow`, `NetBind`, `PortRemap` |
| HTTP ACL | `HTTPAllow`, `HTTPDeny`, `HTTPPorts`, `HTTPCAFile`, `HTTPKeyFile` |
| Resources | `MaxMemory`, `MaxDisk`, `MaxProcesses`, `MaxCPU`, `MaxOpenFiles`, `CPUCores`, `NumCPUs`, `GPUDevices` |
| Syscalls | `ExtraAllowSyscalls`, `ExtraDenySyscalls` |
| Determinism | `RandomSeed`, `TimeStart`, `NoRandomizeMemory`, `NoHugePages`, `DeterministicDirs` |
| Environment | `CleanEnv`, `Env` |
| Misc | `UID`, `NoCoredump`, `Name` |
| COW branch | `FSStorage`, `OnExit`, `OnError` |

`NetAllow` entries follow sandlock's rule grammar: bare `host:port` is TCP
(`"api.openai.com:443"`, `"github.com:22,443"`, `":53"`); scheme prefixes opt
other protocols in (`"udp://1.1.1.1:53"`, `"udp://*:*"`, `"icmp://host"`,
`"icmp://*"`). `NetBind` entries are single ports (`"8080"`) or inclusive
ranges (`"3000-3010"`).

### Execution

```go
func (s *Sandbox) Run(ctx context.Context, cmd ...string) (*Result, error)
func (s *Sandbox) RunInteractive(ctx context.Context, cmd ...string) (int, error)
func (s *Sandbox) DryRun(ctx context.Context, cmd ...string) (*DryRunResult, error)
func (s *Sandbox) Spawn(cmd ...string) (*Process, error)
```

- **Run** captures stdout/stderr and waits. A `ctx` deadline kills the process
and returns a result with `ExitCode == -1`. `ctx` cancellation without a
deadline does not preempt a running child.
- **RunInteractive** inherits the caller's stdio and returns the exit code.
- **DryRun** runs against a temporary copy-on-write layer, reports the
filesystem `Changes` it would have made, and discards them. Requires
`Workdir`.
- **Spawn** starts a process without waiting, returning a `*Process`.

### Process lifecycle

```go
func (p *Process) Pid() int
func (p *Process) Wait() (*Result, error)
func (p *Process) Pause() error // SIGSTOP to the process group
func (p *Process) Resume() error // SIGCONT
func (p *Process) Kill() error // SIGKILL
func (p *Process) Ports() (map[int]int, error) // virtual→real, with PortRemap
func (p *Process) Close() error // release the handle (kills if running)
```

### Confine the current process

```go
func Confine(s *Sandbox) error
```

Applies the sandbox's Landlock filesystem rules to the **current** process, in
place and irreversibly — no fork, no exec. Only filesystem fields are honored;
configuration that needs a supervisor or a fresh child (seccomp, network,
resource limits, environment, ...) is rejected rather than silently ignored.
This is something the `sandlock` CLI cannot do.

### Platform

```go
func LandlockABIVersion() int // kernel's Landlock ABI, or -1
func MinLandlockABI() int // minimum this build requires
func SyscallNr(name string) (int, error)
```

## Status

This SDK covers the static policy surface plus in-process `Confine`. The
following sandlock features are not yet bound and are tracked as follow-ups:
dynamic `policy_fn` callbacks, custom seccomp handlers, pipelines, gather
(fan-in), COW `fork`/`reduce`, and `checkpoint`/restore.

`policy_fn` in particular needs a small upstream addition — a `void *user_data`
parameter on `sandlock_sandbox_builder_policy_fn` — before Go can route the
callback to a per-`Sandbox` closure. See the SDK's tracking issue.

## License

Apache-2.0
11 changes: 11 additions & 0 deletions go/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package sandlock

import "errors"

// ErrInvalidString is returned when a string passed to the SDK contains an
// interior NUL byte, which cannot cross the C ABI boundary intact.
var ErrInvalidString = errors.New("sandlock: string contains NUL byte")

// ErrNotRunning is returned by *Process lifecycle methods when no process is
// currently running in the handle.
var ErrNotRunning = errors.New("sandlock: process is not running")
44 changes: 44 additions & 0 deletions go/examples/basic/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Command basic demonstrates running a command under a sandlock sandbox with a
// read-only root filesystem and a single writable directory.
//
// Build the FFI library first, then run from a sandlock checkout:
//
// cargo build --release
// go run ./go/examples/basic
package main

import (
"context"
"fmt"
"log"
"os"
"time"

sandlock "github.com/multikernel/sandlock/go"
)

func main() {
if v, min := sandlock.LandlockABIVersion(), sandlock.MinLandlockABI(); v < min {
log.Fatalf("kernel Landlock ABI v%d < required v%d", v, min)
}

sb := &sandlock.Sandbox{
FSReadable: []string{"/usr", "/lib", "/lib64", "/bin", "/etc"},
FSWritable: []string{"/tmp"},
MaxMemory: "256M",
}

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

res, err := sb.Run(ctx, "sh", "-c", "echo hello from $(uname -s); ls /tmp >/dev/null")
if err != nil {
log.Fatalf("run: %v", err)
}

fmt.Printf("exit=%d success=%v\n", res.ExitCode, res.Success)
os.Stdout.Write(res.Stdout)
if len(res.Stderr) > 0 {
fmt.Fprintf(os.Stderr, "stderr: %s", res.Stderr)
}
}
3 changes: 3 additions & 0 deletions go/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/multikernel/sandlock/go

go 1.21
99 changes: 99 additions & 0 deletions go/internal/policy/spec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Package policy holds pure, platform-independent parsing helpers shared by
// the sandlock Go SDK. It deliberately has no cgo dependency so the logic can
// be unit-tested on any OS, separate from the Linux-only FFI bindings.
package policy

import (
"fmt"
"regexp"
"slices"
"strconv"
"strings"
"time"
)

var (
sizeRe = regexp.MustCompile(`^\s*(\d+(?:\.\d+)?)\s*([KMGTkmgt])?\s*$`)
portRe = regexp.MustCompile(`^(\d+)(?:-(\d+))?$`)
)

var sizeUnits = map[byte]uint64{
'K': 1 << 10,
'M': 1 << 20,
'G': 1 << 30,
'T': 1 << 40,
}

// ParseMemory parses a human-friendly size string into bytes. It accepts a
// plain integer (bytes) or a value suffixed with K, M, G, or T (case
// insensitive), e.g. "512M", "1G", "100K". Mirrors the Python SDK's
// parse_memory_size so the two SDKs agree byte-for-byte.
func ParseMemory(s string) (uint64, error) {
m := sizeRe.FindStringSubmatch(s)
if m == nil {
return 0, fmt.Errorf("invalid memory size: %q", s)
}
value, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return 0, fmt.Errorf("invalid memory size: %q", s)
}
if m[2] != "" {
unit := sizeUnits[strings.ToUpper(m[2])[0]]
value *= float64(unit)
}
return uint64(value), nil
}

// ParsePorts expands a list of port specs into a sorted, de-duplicated list of
// individual port numbers. Each spec is a single port ("80") or an inclusive
// range ("8000-9000"). Values must fall in [0, 65535].
func ParsePorts(specs []string) ([]uint16, error) {
set := map[uint16]struct{}{}
for _, spec := range specs {
m := portRe.FindStringSubmatch(strings.TrimSpace(spec))
if m == nil {
return nil, fmt.Errorf("invalid port spec: %q", spec)
}
lo, err := strconv.Atoi(m[1])
if err != nil {
return nil, fmt.Errorf("invalid port spec: %q", spec)
}
hi := lo
if m[2] != "" {
hi, err = strconv.Atoi(m[2])
if err != nil {
return nil, fmt.Errorf("invalid port spec: %q", spec)
}
}
if lo > hi || lo < 0 || hi > 65535 {
return nil, fmt.Errorf("invalid port range: %q", spec)
}
for p := lo; p <= hi; p++ {
set[uint16(p)] = struct{}{}
}
}
out := make([]uint16, 0, len(set))
for p := range set {
out = append(out, p)
}
slices.Sort(out)
return out, nil
}

// ParseTimeStart resolves a time-virtualization start point to whole seconds
// since the Unix epoch. It accepts an RFC 3339 / ISO 8601 timestamp
// (e.g. "2000-01-01T00:00:00Z") or a plain integer/float number of seconds.
func ParseTimeStart(s string) (uint64, error) {
s = strings.TrimSpace(s)
if f, err := strconv.ParseFloat(s, 64); err == nil {
if f < 0 {
return 0, fmt.Errorf("invalid time_start: %q", s)
}
return uint64(f), nil
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return 0, fmt.Errorf("invalid time_start: %q (want RFC3339 or unix seconds)", s)
}
return uint64(t.Unix()), nil
}
Loading
Loading