Skip to content

Commit c924c46

Browse files
committed
update the macos sandbox, and policy mode configuration to make things more strict by default with fallback
1 parent 9458a05 commit c924c46

File tree

9 files changed

+645
-64
lines changed

9 files changed

+645
-64
lines changed

.github/workflows/test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@ jobs:
1818
- if: runner.os == 'Linux'
1919
run: sudo apt-get install -y bubblewrap
2020
- run: go test -v -race ./...
21+
- if: runner.os == 'macOS'
22+
run: go test -v -count=1 ./runner

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Shared sandbox settings are configured through `RunnerParams.SandboxConfig`:
3131
- `Type`: explicit backend (`"bubblewrap"`, `"firejail"`, `"flatpak"`) or auto (`""`)
3232
- `NoNetwork`: disable network access for the selected backend
3333
- `AllowEnv`: additional environment variable names to pass through from the host
34+
- `PolicyMode`: backend-specific policy mode (currently used by macOS `sandbox-exec`)
3435

3536
Sandbox backends:
3637

@@ -50,7 +51,13 @@ Backend selection:
5051

5152
### macOS
5253

53-
Uses Apple's `sandbox-exec` with a generated [Seatbelt](https://reverse.put.as/wp-content/uploads/2011/09/Apple-Sandbox-Guide-v1.0.pdf) (SBPL) policy. The policy defaults to deny, then grants access to the game's install folder, system libraries, fonts, audio, and networking. For app bundles, a temporary shim `.app` wrapper is created that invokes `sandbox-exec` inside the bundle structure so that macOS treats it as a proper application.
54+
Uses Apple's `sandbox-exec` with a generated [Seatbelt](https://reverse.put.as/wp-content/uploads/2011/09/Apple-Sandbox-Guide-v1.0.pdf) (SBPL) policy. The policy defaults to deny, then grants access to the game's install folder plus required runtime resources. `SandboxConfig.NoNetwork` is supported on macOS and removes network rules from the generated profile. Environment forwarding in sandbox mode follows a strict allowlist baseline (including itch launch vars) plus `SandboxConfig.AllowEnv`.
55+
56+
For app bundles, a temporary shim `.app` wrapper is created that invokes `sandbox-exec` inside the bundle structure so that macOS treats it as a proper application.
57+
58+
Policy rollout mode can be controlled with `RunnerParams.SandboxConfig.PolicyMode`:
59+
- `"balanced"` (default)
60+
- `"legacy"` (compatibility fallback)
5461

5562
### Windows
5663

runner/app_darwin.go

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@ import (
77
"os"
88
"os/exec"
99
"os/signal"
10+
"sort"
11+
"strconv"
12+
"strings"
1013

1114
"github.com/itchio/ox/macox"
1215
)
1316

17+
var pgrepCommand = exec.Command
18+
var killCommand = exec.Command
19+
var openCommand = exec.Command
20+
1421
type appRunner struct {
1522
params RunnerParams
1623
target *MacLaunchTarget
@@ -82,38 +89,68 @@ func RunAppBundle(params RunnerParams, bundlePath string) error {
8289

8390
consumer.Infof("Actual binary is (%s)", binaryPath)
8491

85-
cmd := exec.Command("open", args...)
92+
cmd := openCommand("open", args...)
8693
// I doubt this matters
8794
cmd.Dir = params.Dir
8895
cmd.Env = params.Env
8996
// 'open' does not relay stdout or stderr, so we don't
9097
// even bother setting them
9198

99+
preLaunchPIDs, err := matchingPIDs(binaryPath)
100+
havePreLaunchSnapshot := err == nil
101+
if err != nil {
102+
consumer.Warnf("Could not snapshot existing app PIDs before launch: %s", err.Error())
103+
}
104+
92105
processDone := make(chan struct{})
106+
interruptSignals := make(chan os.Signal, 1)
107+
signal.Notify(interruptSignals, os.Interrupt)
108+
defer signal.Stop(interruptSignals)
93109

94110
go func() {
95-
// catch SIGINT and kill the child if needed
96-
c := make(chan os.Signal, 1)
97-
signal.Notify(c, os.Interrupt)
98-
99111
consumer.Infof("Signal handler installed...")
100112

101113
// Block until a signal is received.
102114
select {
103115
case <-params.Ctx.Done():
104116
consumer.Warnf("Context done!")
105-
case s := <-c:
117+
case s := <-interruptSignals:
106118
consumer.Warnf("Got signal: %v", s)
107119
case <-processDone:
108120
return
109121
}
110122

111123
consumer.Warnf("Killing app...")
112-
// TODO: kill the actual binary, not the app
113-
cmd := exec.Command("pkill", "-f", binaryPath)
114-
err := cmd.Run()
124+
postLaunchPIDs, err := matchingPIDs(binaryPath)
115125
if err != nil {
116-
consumer.Errorf("While killing: %s", err.Error())
126+
consumer.Errorf("While discovering app PIDs: %s", err.Error())
127+
if cmd.Process != nil {
128+
_ = cmd.Process.Kill()
129+
}
130+
return
131+
}
132+
133+
var killList []int
134+
if havePreLaunchSnapshot {
135+
killList = diffPIDs(postLaunchPIDs, preLaunchPIDs)
136+
} else {
137+
killList = mapKeys(postLaunchPIDs)
138+
}
139+
140+
if len(killList) == 0 {
141+
consumer.Warnf("No launch-specific process IDs found to terminate")
142+
if cmd.Process != nil {
143+
_ = cmd.Process.Kill()
144+
}
145+
return
146+
}
147+
148+
for _, pid := range killList {
149+
killCmd := killCommand("kill", "-TERM", strconv.Itoa(pid))
150+
err = killCmd.Run()
151+
if err != nil {
152+
consumer.Warnf("Could not terminate pid %d: %s", pid, err.Error())
153+
}
117154
}
118155
}()
119156

@@ -125,3 +162,49 @@ func RunAppBundle(params RunnerParams, bundlePath string) error {
125162

126163
return nil
127164
}
165+
166+
func matchingPIDs(binaryPath string) (map[int]struct{}, error) {
167+
cmd := pgrepCommand("pgrep", "-f", binaryPath)
168+
output, err := cmd.Output()
169+
if err != nil {
170+
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
171+
return map[int]struct{}{}, nil
172+
}
173+
return nil, fmt.Errorf("pgrep -f %q: %w", binaryPath, err)
174+
}
175+
176+
pids := make(map[int]struct{})
177+
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
178+
line = strings.TrimSpace(line)
179+
if line == "" {
180+
continue
181+
}
182+
pid, convErr := strconv.Atoi(line)
183+
if convErr != nil {
184+
return nil, fmt.Errorf("parsing pid %q: %w", line, convErr)
185+
}
186+
pids[pid] = struct{}{}
187+
}
188+
return pids, nil
189+
}
190+
191+
func diffPIDs(after map[int]struct{}, before map[int]struct{}) []int {
192+
var out []int
193+
for pid := range after {
194+
if _, found := before[pid]; found {
195+
continue
196+
}
197+
out = append(out, pid)
198+
}
199+
sort.Ints(out)
200+
return out
201+
}
202+
203+
func mapKeys(pidSet map[int]struct{}) []int {
204+
var out []int
205+
for pid := range pidSet {
206+
out = append(out, pid)
207+
}
208+
sort.Ints(out)
209+
return out
210+
}

runner/app_darwin_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//go:build darwin
2+
3+
package runner
4+
5+
import (
6+
"context"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"sync"
11+
"testing"
12+
"time"
13+
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func writeTestAppBundle(t *testing.T) string {
19+
t.Helper()
20+
21+
bundleDir := filepath.Join(t.TempDir(), "TestHelper.app")
22+
macosDir := filepath.Join(bundleDir, "Contents", "MacOS")
23+
require.NoError(t, os.MkdirAll(macosDir, 0755))
24+
25+
execPath := filepath.Join(macosDir, "testhelper")
26+
require.NoError(t, os.WriteFile(execPath, []byte("#!/bin/sh\nexit 0\n"), 0755))
27+
28+
plist := `<?xml version="1.0" encoding="UTF-8"?>
29+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
30+
<plist version="1.0">
31+
<dict>
32+
<key>CFBundleExecutable</key>
33+
<string>testhelper</string>
34+
</dict>
35+
</plist>`
36+
require.NoError(t, os.WriteFile(filepath.Join(bundleDir, "Contents", "Info.plist"), []byte(plist), 0644))
37+
38+
return bundleDir
39+
}
40+
41+
func TestRunAppBundleCancellationKillsOnlyNewPIDs(t *testing.T) {
42+
origPgrep := pgrepCommand
43+
origKill := killCommand
44+
origOpen := openCommand
45+
t.Cleanup(func() {
46+
pgrepCommand = origPgrep
47+
killCommand = origKill
48+
openCommand = origOpen
49+
})
50+
51+
var pgrepCalls int
52+
var killMu sync.Mutex
53+
var killed []string
54+
55+
pgrepCommand = func(name string, args ...string) *exec.Cmd {
56+
pgrepCalls++
57+
if pgrepCalls == 1 {
58+
return exec.Command("sh", "-c", "printf '10\n20\n'")
59+
}
60+
return exec.Command("sh", "-c", "printf '10\n20\n30\n40\n'")
61+
}
62+
killCommand = func(name string, args ...string) *exec.Cmd {
63+
killMu.Lock()
64+
killed = append(killed, args[len(args)-1])
65+
killMu.Unlock()
66+
return exec.Command("sh", "-c", "true")
67+
}
68+
openCommand = func(name string, args ...string) *exec.Cmd {
69+
return exec.Command("sh", "-c", "sleep 0.2")
70+
}
71+
72+
bundlePath := writeTestAppBundle(t)
73+
ctx, cancel := context.WithCancel(context.Background())
74+
defer cancel()
75+
76+
go func() {
77+
time.Sleep(50 * time.Millisecond)
78+
cancel()
79+
}()
80+
81+
err := RunAppBundle(
82+
RunnerParams{
83+
Consumer: newSandboxExecTestConsumer(t),
84+
Ctx: ctx,
85+
},
86+
bundlePath,
87+
)
88+
require.NoError(t, err)
89+
90+
killMu.Lock()
91+
defer killMu.Unlock()
92+
assert.Equal(t, []string{"30", "40"}, killed)
93+
}
94+
95+
func TestDiffPIDsIsSortedAndExcludesExisting(t *testing.T) {
96+
after := map[int]struct{}{
97+
30: {},
98+
40: {},
99+
20: {},
100+
}
101+
before := map[int]struct{}{
102+
20: {},
103+
}
104+
105+
assert.Equal(t, []int{30, 40}, diffPIDs(after, before))
106+
}

runner/env_allowlist_darwin.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//go:build darwin
2+
3+
package runner
4+
5+
import "strings"
6+
7+
var darwinBaseSandboxEnvVars = []string{
8+
"HOME",
9+
"USER",
10+
"LOGNAME",
11+
"PATH",
12+
"LANG",
13+
"LC_ALL",
14+
"LC_CTYPE",
15+
"TMP",
16+
"TEMP",
17+
"TMPDIR",
18+
}
19+
20+
func darwinSandboxEnvAllowlist() []string {
21+
allowlist := make([]string, 0, len(darwinBaseSandboxEnvVars)+len(ItchioLaunchEnvVars))
22+
allowlist = append(allowlist, darwinBaseSandboxEnvVars...)
23+
allowlist = append(allowlist, ItchioLaunchEnvVars...)
24+
return allowlist
25+
}
26+
27+
func collectAllowedEnvDarwin(paramsEnv []string, hostEnv []string, extraKeys []string) []string {
28+
allowlist := darwinSandboxEnvAllowlist()
29+
allowlist = append(allowlist, extraKeys...)
30+
out := make([]string, 0, len(allowlist))
31+
for _, key := range allowlist {
32+
if val, found := envLookupWithPresenceDarwin(paramsEnv, key); found {
33+
out = append(out, key+"="+val)
34+
continue
35+
}
36+
if val, found := envLookupWithPresenceDarwin(hostEnv, key); found {
37+
out = append(out, key+"="+val)
38+
}
39+
}
40+
return out
41+
}
42+
43+
func envLookupWithPresenceDarwin(env []string, key string) (string, bool) {
44+
prefix := key + "="
45+
for _, e := range env {
46+
if strings.HasPrefix(e, prefix) {
47+
return e[len(prefix):], true
48+
}
49+
}
50+
return "", false
51+
}

0 commit comments

Comments
 (0)