Skip to content

Commit 2d039ec

Browse files
authored
Merge pull request #149 from docker/sandboxing
Implement llama.cpp sandboxing for macOS and Windows
2 parents 229e081 + 935aab9 commit 2d039ec

File tree

9 files changed

+430
-22
lines changed

9 files changed

+430
-22
lines changed

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
github.com/google/go-containerregistry v0.20.3
1111
github.com/gpustack/gguf-parser-go v0.14.1
1212
github.com/jaypipes/ghw v0.16.0
13+
github.com/kolesnikovae/go-winjob v1.0.0
1314
github.com/mattn/go-shellwords v1.0.12
1415
github.com/opencontainers/go-digest v1.0.0
1516
github.com/opencontainers/image-spec v1.1.1
@@ -59,7 +60,7 @@ require (
5960
golang.org/x/crypto v0.37.0 // indirect
6061
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
6162
golang.org/x/mod v0.24.0 // indirect
62-
golang.org/x/sys v0.33.0 // indirect
63+
golang.org/x/sys v0.35.0 // indirect
6364
golang.org/x/tools v0.32.0 // indirect
6465
gonum.org/v1/gonum v0.15.1 // indirect
6566
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect
@@ -68,3 +69,5 @@ require (
6869
gopkg.in/yaml.v3 v3.0.1 // indirect
6970
howett.net/plist v1.0.0 // indirect
7071
)
72+
73+
replace github.com/kolesnikovae/go-winjob => github.com/docker/go-winjob v0.0.0-20250829235554-57b487ebcbc5

go.sum

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBi
3838
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
3939
github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo=
4040
github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
41+
github.com/docker/go-winjob v0.0.0-20250829235554-57b487ebcbc5 h1:dxSFEb0EEmvceIawSFNDMrvKakRz2t+2WYpY3dFAT04=
42+
github.com/docker/go-winjob v0.0.0-20250829235554-57b487ebcbc5/go.mod h1:ICOGmIXdwhfid7rQP+tLvDJqVg0lHdEk3pI5nsapTtg=
4143
github.com/docker/model-distribution v0.0.0-20250822172258-8fe9daa4a4da h1:ml99WBfcLnsy1frXQR4X+5WAC0DoGtwZyGoU/xBsDQM=
4244
github.com/docker/model-distribution v0.0.0-20250822172258-8fe9daa4a4da/go.mod h1:dThpO9JoG5Px3i+rTluAeZcqLGw8C0qepuEL4gL2o/c=
4345
github.com/elastic/go-sysinfo v1.15.3 h1:W+RnmhKFkqPTCRoFq2VCTmsT4p/fwpo+3gKNQsn1XU0=
@@ -156,8 +158,8 @@ golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
156158
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
157159
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
158160
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
159-
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
160-
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
161+
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
162+
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
161163
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
162164
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
163165
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=

pkg/inference/backends/llamacpp/llamacpp.go

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package llamacpp
22

33
import (
44
"bufio"
5+
"bytes"
56
"context"
67
"errors"
78
"fmt"
@@ -24,6 +25,7 @@ import (
2425
"github.com/docker/model-runner/pkg/inference/config"
2526
"github.com/docker/model-runner/pkg/inference/models"
2627
"github.com/docker/model-runner/pkg/logging"
28+
"github.com/docker/model-runner/pkg/sandbox"
2729
"github.com/docker/model-runner/pkg/tailbuffer"
2830
)
2931

@@ -153,30 +155,33 @@ func (l *llamaCpp) Run(ctx context.Context, socket, model string, mode inference
153155
}
154156

155157
l.log.Infof("llamaCppArgs: %v", args)
156-
llamaCppProcess := exec.CommandContext(
158+
tailBuf := tailbuffer.NewTailBuffer(1024)
159+
serverLogStream := l.serverLog.Writer()
160+
out := io.MultiWriter(serverLogStream, tailBuf)
161+
llamaCppSandbox, err := sandbox.Create(
157162
ctx,
163+
sandbox.ConfigurationLlamaCpp,
164+
func(command *exec.Cmd) {
165+
command.Cancel = func() error {
166+
if runtime.GOOS == "windows" {
167+
return command.Process.Kill()
168+
}
169+
return command.Process.Signal(os.Interrupt)
170+
}
171+
command.Stdout = serverLogStream
172+
command.Stderr = out
173+
},
158174
filepath.Join(binPath, "com.docker.llama-server"),
159175
args...,
160176
)
161-
llamaCppProcess.Cancel = func() error {
162-
if runtime.GOOS == "windows" {
163-
return llamaCppProcess.Process.Kill()
164-
}
165-
return llamaCppProcess.Process.Signal(os.Interrupt)
166-
}
167-
tailBuf := tailbuffer.NewTailBuffer(1024)
168-
serverLogStream := l.serverLog.Writer()
169-
out := io.MultiWriter(serverLogStream, tailBuf)
170-
llamaCppProcess.Stdout = serverLogStream
171-
llamaCppProcess.Stderr = out
172-
173-
if err := llamaCppProcess.Start(); err != nil {
177+
if err != nil {
174178
return fmt.Errorf("unable to start llama.cpp: %w", err)
175179
}
180+
defer llamaCppSandbox.Close()
176181

177182
llamaCppErrors := make(chan error, 1)
178183
go func() {
179-
llamaCppErr := llamaCppProcess.Wait()
184+
llamaCppErr := llamaCppSandbox.Command().Wait()
180185
serverLogStream.Close()
181186

182187
errOutput := new(strings.Builder)
@@ -351,16 +356,27 @@ func (l *llamaCpp) checkGPUSupport(ctx context.Context) bool {
351356
if l.updatedLlamaCpp {
352357
binPath = l.updatedServerStoragePath
353358
}
354-
out, err := exec.CommandContext(
359+
var output bytes.Buffer
360+
llamaCppSandbox, err := sandbox.Create(
355361
ctx,
362+
sandbox.ConfigurationLlamaCpp,
363+
func(command *exec.Cmd) {
364+
command.Stdout = &output
365+
command.Stderr = &output
366+
},
356367
filepath.Join(binPath, "com.docker.llama-server"),
357368
"--list-devices",
358-
).CombinedOutput()
369+
)
359370
if err != nil {
360-
l.log.Warnf("Failed to determine if llama-server is built with GPU support: %s", err)
371+
l.log.Warnf("Failed to start sandboxed llama.cpp process to probe GPU support: %v", err)
372+
return false
373+
}
374+
defer llamaCppSandbox.Close()
375+
if err := llamaCppSandbox.Command().Wait(); err != nil {
376+
l.log.Warnf("Failed to determine if llama-server is built with GPU support: %v", err)
361377
return false
362378
}
363-
sc := bufio.NewScanner(strings.NewReader(string(out)))
379+
sc := bufio.NewScanner(strings.NewReader(string(output.Bytes())))
364380
expectDev := false
365381
devRe := regexp.MustCompile(`\s{2}.*:\s`)
366382
ndevs := 0

pkg/sandbox/sandbox.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package sandbox
2+
3+
import (
4+
"os/exec"
5+
)
6+
7+
// Sandbox encapsulates a single running sandboxed process.
8+
type Sandbox interface {
9+
// Command returns the sandboxed process handle.
10+
Command() *exec.Cmd
11+
// Close closes the sandbox, terminating the process if it's still running.
12+
Close() error
13+
}

pkg/sandbox/sandbox_darwin.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package sandbox
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"os/user"
9+
"strings"
10+
)
11+
12+
// ConfigurationLlamaCpp is the sandbox configuration for llama.cpp processes.
13+
const ConfigurationLlamaCpp = `(version 1)
14+
15+
;;; Keep a default allow policy (because encoding things like DYLD support and
16+
;;; device access is quite difficult), but deny critical exploitation targets
17+
;;; (generally aligned with the App Sandbox entitlements that aren't on by
18+
;;; default). In theory we'll be subject to the Docker.app sandbox as well
19+
;;; (unless we're running standalone), but even Docker.app has a very privileged
20+
;;; sandbox, so we need additional constraints.
21+
;;;
22+
;;; Note: The following are known to be required at some level for llama.cpp
23+
;;; (though we could further experiment to deny certain sub-permissions):
24+
;;; - authorization
25+
;;; - darwin
26+
;;; - iokit
27+
;;; - mach
28+
;;; - socket
29+
;;; - syscall
30+
;;; - process
31+
(allow default)
32+
33+
;;; Deny network access, except for our IPC sockets.
34+
;;; NOTE: We use different socket nomenclature when running in Docker Desktop
35+
;;; (inference-N.sock) vs. standalone (inference-runner-N.sock), so we use a
36+
;;; wildcard to support both.
37+
(deny network*)
38+
(allow network-bind network-inbound
39+
(regex #"inference.*-[0-9]+\.sock$"))
40+
41+
;;; Deny access to the camera and microphone.
42+
(deny device*)
43+
44+
;;; Deny access to NVRAM settings.
45+
(deny nvram*)
46+
47+
;;; Deny access to system-level privileges.
48+
(deny system*)
49+
50+
;;; Deny access to job creation.
51+
(deny job-creation)
52+
53+
;;; Don't allow new executable code to be created in memory at runtime.
54+
(deny dynamic-code-generation)
55+
56+
;;; Disable access to user preferences.
57+
(deny user-preference*)
58+
59+
;;; Restrict file access.
60+
;;; NOTE: For some reason, the (home-subpath "...") predicate used in system
61+
;;; sandbox profiles doesn't work with sandbox-exec.
62+
;;; NOTE: We have to allow access to the working directory for standalone mode.
63+
;;; NOTE: We have to allow access to a regex-based Docker.app location to
64+
;;; support Docker Desktop development as well as Docker.app installs that don't
65+
;;; live inside /Applications.
66+
;;; NOTE: For some reason (deny file-read*) really doesn't like to play nice
67+
;;; with llama.cpp, so for that reason we'll avoid a blanket ban and just ban
68+
;;; directories that might contain sensitive data.
69+
(deny file-map-executable)
70+
(deny file-write*)
71+
(deny file-read*
72+
(subpath "/Applications")
73+
(subpath "/private/etc")
74+
(subpath "/Library")
75+
(subpath "/Users")
76+
(subpath "/Volumes"))
77+
(allow file-read* file-map-executable
78+
(subpath "/usr")
79+
(subpath "/System")
80+
(regex #"Docker\.app/Contents/Resources/model-runner")
81+
(subpath "[HOMEDIR]/.docker/bin/inference")
82+
(subpath "[HOMEDIR]/.docker/bin/lib"))
83+
(allow file-write*
84+
(literal "/dev/null")
85+
(subpath "/private/var")
86+
(subpath "[HOMEDIR]/Library/Containers/com.docker.docker/Data")
87+
(subpath "[WORKDIR]"))
88+
(allow file-read*
89+
(subpath "[HOMEDIR]/.docker/models")
90+
(subpath "[HOMEDIR]/Library/Containers/com.docker.docker/Data")
91+
(subpath "[WORKDIR]"))
92+
`
93+
94+
// sandbox is the Darwin sandbox implementation.
95+
type sandbox struct {
96+
// cancel cancels the context associated with the process.
97+
cancel context.CancelFunc
98+
// command is the sandboxed process handle.
99+
command *exec.Cmd
100+
}
101+
102+
// Command implements Sandbox.Command.
103+
func (s *sandbox) Command() *exec.Cmd {
104+
return s.command
105+
}
106+
107+
// Command implements Sandbox.Close.
108+
func (s *sandbox) Close() error {
109+
s.cancel()
110+
return nil
111+
}
112+
113+
// Create creates a sandbox containing a single process that has been started.
114+
// The ctx, name, and arg arguments correspond to their counterparts in
115+
// os/exec.CommandContext. The configuration argument specifies the sandbox
116+
// configuration, for which a pre-defined value should be used. The modifier
117+
// function allows for an optional callback (which may be nil) to configure the
118+
// command before it is started.
119+
func Create(ctx context.Context, configuration string, modifier func(*exec.Cmd), name string, arg ...string) (Sandbox, error) {
120+
// Look up the user's home directory.
121+
currentUser, err := user.Current()
122+
if err != nil {
123+
return nil, fmt.Errorf("unable to lookup user: %w", err)
124+
}
125+
126+
// Look up the working directory.
127+
currentDirectory, err := os.Getwd()
128+
if err != nil {
129+
return nil, fmt.Errorf("unable to determine working directory: %w", err)
130+
}
131+
132+
// Process template arguments in the configuration. We should switch to
133+
// text/template if this gets any more complex.
134+
profile := strings.ReplaceAll(configuration, "[HOMEDIR]", currentUser.HomeDir)
135+
profile = strings.ReplaceAll(profile, "[WORKDIR]", currentDirectory)
136+
137+
// Create a subcontext we can use to regulate the process lifetime.
138+
ctx, cancel := context.WithCancel(ctx)
139+
140+
// Create and configure the command.
141+
sandboxedArgs := make([]string, 0, len(arg)+3)
142+
sandboxedArgs = append(sandboxedArgs, "-p", profile, name)
143+
sandboxedArgs = append(sandboxedArgs, arg...)
144+
command := exec.CommandContext(ctx, "sandbox-exec", sandboxedArgs...)
145+
if modifier != nil {
146+
modifier(command)
147+
}
148+
149+
// Start the process.
150+
if err := command.Start(); err != nil {
151+
cancel()
152+
return nil, fmt.Errorf("unable to start sandboxed process: %w", err)
153+
}
154+
return &sandbox{
155+
cancel: cancel,
156+
command: command,
157+
}, nil
158+
}

pkg/sandbox/sandbox_other.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
//go:build !darwin && !windows
2+
3+
package sandbox
4+
5+
import (
6+
"context"
7+
"fmt"
8+
"os/exec"
9+
)
10+
11+
// ConfigurationLlamaCpp is the sandbox configuration for llama.cpp processes.
12+
const ConfigurationLlamaCpp = ``
13+
14+
// sandbox is the non-Darwin POSIX sandbox implementation.
15+
type sandbox struct {
16+
// cancel cancels the context associated with the process.
17+
cancel context.CancelFunc
18+
// command is the sandboxed process handle.
19+
command *exec.Cmd
20+
}
21+
22+
// Command implements Sandbox.Command.
23+
func (s *sandbox) Command() *exec.Cmd {
24+
return s.command
25+
}
26+
27+
// Command implements Sandbox.Close.
28+
func (s *sandbox) Close() error {
29+
s.cancel()
30+
return nil
31+
}
32+
33+
// Create creates a sandbox containing a single process that has been started.
34+
// The ctx, name, and arg arguments correspond to their counterparts in
35+
// os/exec.CommandContext. The configuration argument specifies the sandbox
36+
// configuration, for which a pre-defined value should be used. The modifier
37+
// function allows for an optional callback (which may be nil) to configure the
38+
// command before it is started.
39+
func Create(ctx context.Context, configuration string, modifier func(*exec.Cmd), name string, arg ...string) (Sandbox, error) {
40+
// Create a subcontext we can use to regulate the process lifetime.
41+
ctx, cancel := context.WithCancel(ctx)
42+
43+
// Create and configure the command.
44+
command := exec.CommandContext(ctx, name, arg...)
45+
if modifier != nil {
46+
modifier(command)
47+
}
48+
49+
// Start the process.
50+
if err := command.Start(); err != nil {
51+
cancel()
52+
return nil, fmt.Errorf("unable to start process: %w", err)
53+
}
54+
return &sandbox{
55+
cancel: cancel,
56+
command: command,
57+
}, nil
58+
}

0 commit comments

Comments
 (0)