Skip to content
Closed
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
9 changes: 8 additions & 1 deletion examples/ollama/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,19 @@ func main() {
// you only ever pull a model once.
// CUDA_VISIBLE_DEVICES is intentionally left empty here; set it if you
// want to pin different workers to different GPUs.
//
// Seccomp note: Ollama is an opaque binary — it cannot call
// herd.EnterSandbox() to install its own syscall filter. We opt out of
// seccomp here. Namespace + cgroup isolation still applies.
// For Go worker binaries you control, call herd.EnterSandbox() at the top
// of main() and remove WithSeccompPolicy to use the default (errno) policy.
factory := herd.NewProcessFactory("ollama", "serve").
WithEnv("OLLAMA_HOST=127.0.0.1:{{.Port}}").
WithEnv("OLLAMA_MODELS=" + *modelsDir).
WithHealthPath("/"). // ollama: GET / → 200 "Ollama is running"
WithStartTimeout(2 * time.Minute).
WithStartHealthCheckDelay(1 * time.Second)
WithStartHealthCheckDelay(1 * time.Second).
WithSeccompPolicy(herd.SeccompPolicyOff) // opaque binary — cannot call EnterSandbox()

// ── Pool ───────────────────────────────────────────────────────────────
pool, err := herd.New(factory,
Expand Down
9 changes: 8 additions & 1 deletion examples/playwright/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,21 @@ func main() {
//
// We use the CLI directly inside ProcessFactory:
// npx playwright run-server --port {{.Port}} --host 127.0.0.1
//
// Seccomp note: Playwright is an opaque Node.js binary — it cannot call
// herd.EnterSandbox() to install its own syscall filter. We opt out of
// seccomp here. Namespace + cgroup isolation still applies.
// For Go worker binaries you control, call herd.EnterSandbox() at the top
// of main() and remove WithSeccompPolicy to use the default (errno) policy.
factory := herd.NewProcessFactory(
"npx", "playwright", "run-server",
"--port", "{{.Port}}",
"--host", "127.0.0.1",
).
WithHealthPath("/").
WithStartTimeout(1 * time.Minute).
WithStartHealthCheckDelay(500 * time.Millisecond)
WithStartHealthCheckDelay(500 * time.Millisecond).
WithSeccompPolicy(herd.SeccompPolicyOff) // opaque binary — cannot call EnterSandbox()

// ── Pool ───────────────────────────────────────────────────────────────
// To make a bulletproof multi-tenant tool and avoid shared fate, state leaks,
Expand Down
9 changes: 8 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
module github.com/hackstrix/herd

go 1.22
go 1.25.0

require (
github.com/elastic/go-seccomp-bpf v1.6.0
golang.org/x/net v0.52.0
)

require golang.org/x/sys v0.42.0 // indirect
14 changes: 14 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elastic/go-seccomp-bpf v1.6.0 h1:NYduiYxRJ0ZkIyQVwlSskcqPPSg6ynu5pK0/d7SQATs=
github.com/elastic/go-seccomp-bpf v1.6.0/go.mod h1:5tFsTvH4NtWGfpjsOQD53H8HdVQ+zSZFRUDSGevC0Kc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
28 changes: 27 additions & 1 deletion process_worker_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ type ProcessFactory struct {
cgroupMemory int64 // bytes; 0 means unlimited
cgroupCPU int64 // quota in micros per 100ms period; 0 means unlimited
cgroupPIDs int64 // max pids; -1 means unlimited
seccompPolicy SeccompPolicy // syscall filter enforcement mode; default SeccompPolicyErrno
counter atomic.Int64
}

Expand All @@ -196,6 +197,7 @@ func NewProcessFactory(binary string, args ...string) *ProcessFactory {
enableSandbox: true,
namespaceCloneFlags: defaultNamespaceCloneFlags(),
cgroupPIDs: 100,
seccompPolicy: SeccompPolicyErrno,
}
}

Expand Down Expand Up @@ -284,6 +286,21 @@ func (f *ProcessFactory) WithInsecureSandbox() *ProcessFactory {
return f
}

// WithSeccompPolicy sets the seccomp syscall-filter enforcement mode for
// workers spawned by this factory.
//
// The filter is installed by the worker binary itself at startup via
// [EnterSandbox]. The factory injects HERD_SECCOMP_PROFILE into the worker
// environment to communicate the chosen policy.
//
// Defaults to [SeccompPolicyErrno] (unauthorized syscalls return EPERM).
// Use [SeccompPolicyOff] to disable seccomp (e.g. when the worker binary
// does not call EnterSandbox).
func (f *ProcessFactory) WithSeccompPolicy(p SeccompPolicy) *ProcessFactory {
f.seccompPolicy = p
return f
}

func streamLogs(workerID string, pipe io.ReadCloser, isError bool) {
// bufio.Scanner guarantees we read line-by-line, preventing torn logs.
scanner := bufio.NewScanner(pipe)
Expand Down Expand Up @@ -325,7 +342,14 @@ func (f *ProcessFactory) Spawn(ctx context.Context) (Worker[*http.Client], error

// During program exits, this should be cleaned up by the Shutdown method
cmd := exec.Command(f.binary, resolvedArgs...)
cmd.Env = append(os.Environ(), append([]string{"PORT=" + portStr}, resolvedEnv...)...)

// Base environment: inherit parent + port + user extras + seccomp profile
baseEnv := []string{"PORT=" + portStr}
if f.enableSandbox && f.seccompPolicy != SeccompPolicyOff {
baseEnv = append(baseEnv, "HERD_SECCOMP_PROFILE="+f.seccompPolicy.envValue())
}
cmd.Env = append(os.Environ(), append(baseEnv, resolvedEnv...)...)

var cgroupHandle sandboxHandle

if f.enableSandbox {
Expand All @@ -334,6 +358,8 @@ func (f *ProcessFactory) Spawn(ctx context.Context) (Worker[*http.Client], error
cpuMaxMicros: f.cgroupCPU,
pidsMax: f.cgroupPIDs,
cloneFlags: f.namespaceCloneFlags,
noNewPrivs: true,
seccompPolicy: f.seccompPolicy,
})
if err != nil {
return nil, fmt.Errorf("herd: ProcessFactory: failed to apply sandbox: %w", err)
Expand Down
2 changes: 2 additions & 0 deletions sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ type sandboxConfig struct {
cpuMaxMicros int64
pidsMax int64
cloneFlags uintptr
noNewPrivs bool // prevent privilege escalation via setuid binaries
seccompPolicy SeccompPolicy // syscall filter enforcement mode
}

// sandboxHandle owns post-start and cleanup hooks for sandbox resources.
Expand Down
11 changes: 11 additions & 0 deletions sandbox_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"path/filepath"
"strconv"
"syscall"

"golang.org/x/sys/unix"
)

const (
Expand Down Expand Up @@ -119,6 +121,15 @@ func applySandboxFlags(cmd *exec.Cmd, workerID string, cfg sandboxConfig) (sandb
sys.UseCgroupFD = true
cmd.SysProcAttr = sys

if cfg.noNewPrivs {
// Set no_new_privs on the calling OS thread. The bit is inherited
// by all children of this thread (including the forked worker).
// This prevents workers from gaining privileges via setuid binaries.
if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
log.Printf("[sandbox:%s] WARNING: prctl PR_SET_NO_NEW_PRIVS failed: %v; continuing without no_new_privs", workerID, err)
}
}

return &cgroupHandle{path: cgroupPath, fd: dir}, nil
}

Expand Down
38 changes: 37 additions & 1 deletion sandbox_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,43 @@
h.Cleanup() // dir already gone — should not panic or log error as warning
}

func TestCgroupHandle_Cleanup_NilSafe(t *testing.T) {
func TestApplySandboxFlags_NilSafe(t *testing.T) {
var h *cgroupHandle
h.Cleanup() // must not panic
}

// ---------------------------------------------------------------------------
// No-New-Privs tests
// ---------------------------------------------------------------------------

func TestApplySandboxFlags_NoNewPrivs(t *testing.T) {
withTempCgroupRoot(t)
cmd := newFakeCmd()

_, err := applySandboxFlags(cmd, "worker-nnp", sandboxConfig{noNewPrivs: true})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cmd.SysProcAttr == nil {
t.Fatal("SysProcAttr should be set")
}
if !cmd.SysProcAttr.NoNewPrivs {

Check failure on line 287 in sandbox_linux_test.go

View workflow job for this annotation

GitHub Actions / Cgroup Integration Tests

cmd.SysProcAttr.NoNewPrivs undefined (type *syscall.SysProcAttr has no field or method NoNewPrivs)

Check failure on line 287 in sandbox_linux_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.26.x)

cmd.SysProcAttr.NoNewPrivs undefined (type *syscall.SysProcAttr has no field or method NoNewPrivs)

Check failure on line 287 in sandbox_linux_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.23.x)

cmd.SysProcAttr.NoNewPrivs undefined (type *"syscall".SysProcAttr has no field or method NoNewPrivs)

Check failure on line 287 in sandbox_linux_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.22.x)

cmd.SysProcAttr.NoNewPrivs undefined (type *"syscall".SysProcAttr has no field or method NoNewPrivs)

Check failure on line 287 in sandbox_linux_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.24.x)

cmd.SysProcAttr.NoNewPrivs undefined (type *"syscall".SysProcAttr has no field or method NoNewPrivs)

Check failure on line 287 in sandbox_linux_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.25.x)

cmd.SysProcAttr.NoNewPrivs undefined (type *"syscall".SysProcAttr has no field or method NoNewPrivs)
t.Error("NoNewPrivs should be true when noNewPrivs=true")
}
}

func TestApplySandboxFlags_NoNewPrivsOff(t *testing.T) {
withTempCgroupRoot(t)
cmd := newFakeCmd()

_, err := applySandboxFlags(cmd, "worker-nnp-off", sandboxConfig{noNewPrivs: false})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cmd.SysProcAttr == nil {
t.Fatal("SysProcAttr should be set")
}
if cmd.SysProcAttr.NoNewPrivs {

Check failure on line 303 in sandbox_linux_test.go

View workflow job for this annotation

GitHub Actions / Cgroup Integration Tests

cmd.SysProcAttr.NoNewPrivs undefined (type *syscall.SysProcAttr has no field or method NoNewPrivs)

Check failure on line 303 in sandbox_linux_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.26.x)

cmd.SysProcAttr.NoNewPrivs undefined (type *syscall.SysProcAttr has no field or method NoNewPrivs)

Check failure on line 303 in sandbox_linux_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.23.x)

cmd.SysProcAttr.NoNewPrivs undefined (type *"syscall".SysProcAttr has no field or method NoNewPrivs)

Check failure on line 303 in sandbox_linux_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.22.x)

cmd.SysProcAttr.NoNewPrivs undefined (type *"syscall".SysProcAttr has no field or method NoNewPrivs)

Check failure on line 303 in sandbox_linux_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.24.x)

cmd.SysProcAttr.NoNewPrivs undefined (type *"syscall".SysProcAttr has no field or method NoNewPrivs)

Check failure on line 303 in sandbox_linux_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.25.x)

cmd.SysProcAttr.NoNewPrivs undefined (type *"syscall".SysProcAttr has no field or method NoNewPrivs)
t.Error("NoNewPrivs should be false when noNewPrivs=false")
}
}
Loading
Loading