Skip to content

Commit 09d725b

Browse files
committed
feat: add NixOS support
On NixOS, executables live under /nix/store and PATH entries such as /run/current-system/sw/bin are symlinks into the store. Inside the bwrap sandbox /run is replaced with a tmpfs, so those symlinks become dangling. - Replaces the hardcoded /bin/true probe with exec.LookPath("true"), which works on NixOS and any distro that does not provide /bin/true. - Mount /nix read-only in bwrap (like /usr, /opt) and add it to Landlock read paths so store binaries are reachable inside the sandbox. - resolveToolPath: resolve the directory component of shell/sleep paths to their real /nix/store location while preserving the basename so multi-call binaries (coreutils) still dispatch via argv[0]. - resolvePathInEnv: rewrite PATH entries in the hardened env to their real paths and deduplicate, making the sandbox PATH consistent with what is actually mounted.
1 parent 1ab2de3 commit 09d725b

4 files changed

Lines changed: 76 additions & 12 deletions

File tree

internal/sandbox/linux.go

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,16 @@ func fileExists(path string) bool {
639639
return err == nil
640640
}
641641

642+
// resolveToolPath resolves only the directory component so multi-call binaries
643+
// (e.g. coreutils: sleep -> coreutils) still dispatch correctly via argv[0].
644+
func resolveToolPath(p string) string {
645+
dir := filepath.Dir(p)
646+
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
647+
return filepath.Join(resolved, filepath.Base(p))
648+
}
649+
return p
650+
}
651+
642652
// isDirectory returns true if the path exists and is a directory.
643653
func isDirectory(path string) bool {
644654
info, err := os.Stat(path)
@@ -807,7 +817,8 @@ func buildDenyByDefaultMounts(cfg *config.Config, cwd string, dbusBridge *DbusBr
807817
// /bin, /sbin, /lib, /lib64 are often symlinks to /usr/*. We must
808818
// recreate these as symlinks via --symlink so the dynamic linker
809819
// and shell can be found. Real directories get bind-mounted.
810-
systemPaths := []string{"/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt"}
820+
// /nix is included for NixOS where all binaries live under /nix/store.
821+
systemPaths := []string{"/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt", "/nix"}
811822
for _, p := range systemPaths {
812823
if !fileExists(p) {
813824
continue
@@ -1086,6 +1097,14 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
10861097
if err != nil {
10871098
return "", fmt.Errorf("shell %q not found: %w", shell, err)
10881099
}
1100+
shellPath = resolveToolPath(shellPath)
1101+
1102+
sleepPath, err := exec.LookPath("sleep")
1103+
if err != nil {
1104+
sleepPath = "sleep" // fallback to PATH lookup inside sandbox
1105+
} else {
1106+
sleepPath = resolveToolPath(sleepPath)
1107+
}
10891108

10901109
cwd, _ := os.Getwd()
10911110
features := DetectLinuxFeatures()
@@ -1542,18 +1561,18 @@ export no_proxy=localhost,127.0.0.1
15421561
}
15431562

15441563
// Add cleanup function
1545-
innerScript.WriteString(`
1564+
fmt.Fprintf(&innerScript, `
15461565
# Cleanup function
15471566
cleanup() {
15481567
jobs -p | xargs -r kill 2>/dev/null
15491568
}
15501569
trap cleanup EXIT
15511570
15521571
# Small delay to ensure services are ready
1553-
sleep 0.3
1572+
%s 0.3
15541573
15551574
# Run the user command
1556-
`)
1575+
`, ShellQuoteSingle(sleepPath))
15571576

15581577
// In learning mode, wrap the command with strace to trace syscalls.
15591578
// Run strace in the foreground so the traced command retains terminal
@@ -1571,10 +1590,10 @@ GREYWALL_STRACE_EXIT=$?
15711590
# Kill any orphaned child processes (LSP servers, file watchers, etc.)
15721591
# that were spawned by the traced command and reparented to PID 1.
15731592
kill -TERM -1 2>/dev/null
1574-
sleep 0.1
1593+
%s 0.1
15751594
exit $GREYWALL_STRACE_EXIT
15761595
`,
1577-
ShellQuoteSingle(opts.StraceLogPath), command,
1596+
ShellQuoteSingle(opts.StraceLogPath), command, ShellQuoteSingle(sleepPath),
15781597
)
15791598
case useLandlockWrapper:
15801599
// Use Landlock wrapper if available

internal/sandbox/linux_features.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -209,11 +209,17 @@ func (f *LinuxFeatures) detectNetworkNamespace() {
209209
return
210210
}
211211

212-
// Run a minimal bwrap command with --unshare-net to test if it works
213-
// We use a very short timeout since this should either succeed or fail immediately
214-
// The bind mount is required in some environments
215-
cmd := exec.Command("bwrap", "--unshare-net", "--ro-bind", "/", "/", "--", "/bin/true")
216-
err := cmd.Run()
212+
// Run a minimal bwrap command with --unshare-net to test if it works.
213+
// Resolve true from PATH instead of hardcoding /bin/true: distributions like
214+
// NixOS do not provide /bin/true, and a missing probe binary should not be
215+
// mistaken for unavailable network namespaces.
216+
truePath, err := exec.LookPath("true")
217+
if err != nil {
218+
return
219+
}
220+
221+
cmd := exec.Command("bwrap", "--unshare-net", "--ro-bind", "/", "/", "--", truePath)
222+
err = cmd.Run()
217223
f.CanUnshareNet = err == nil
218224
}
219225

internal/sandbox/linux_landlock.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin
6060
"/var/lib",
6161
"/var/cache",
6262
"/opt",
63+
"/nix", // NixOS: all binaries and libraries live under /nix/store
6364
}
6465

6566
for _, p := range systemReadPaths {

internal/sandbox/sanitize.go

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package sandbox
22

33
import (
44
"os"
5+
"path/filepath"
56
"runtime"
67
"strings"
78
)
@@ -45,7 +46,44 @@ var DangerousEnvVars = []string{
4546
// agent writes a .so/.dylib and then uses LD_PRELOAD/DYLD_INSERT_LIBRARIES
4647
// in a subsequent command.
4748
func GetHardenedEnv() []string {
48-
return FilterDangerousEnv(os.Environ())
49+
env := FilterDangerousEnv(os.Environ())
50+
if runtime.GOOS == "linux" {
51+
env = resolvePathInEnv(env)
52+
}
53+
return env
54+
}
55+
56+
// resolvePathInEnv rewrites the PATH entry in env so that each directory is
57+
// resolved to its real path via symlink evaluation. This is needed on NixOS
58+
// where PATH contains /run/current-system/sw/bin (a symlink chain into
59+
// /nix/store) which is unavailable inside the bwrap sandbox because /run is
60+
// replaced with a tmpfs.
61+
func resolvePathInEnv(env []string) []string {
62+
result := make([]string, len(env))
63+
for i, entry := range env {
64+
if !strings.HasPrefix(entry, "PATH=") {
65+
result[i] = entry
66+
continue
67+
}
68+
value := entry[len("PATH="):]
69+
dirs := strings.Split(value, string(filepath.ListSeparator))
70+
seen := make(map[string]bool, len(dirs))
71+
resolved := make([]string, 0, len(dirs))
72+
for _, d := range dirs {
73+
if d == "" {
74+
continue
75+
}
76+
if r, err := filepath.EvalSymlinks(d); err == nil {
77+
d = r
78+
}
79+
if !seen[d] {
80+
seen[d] = true
81+
resolved = append(resolved, d)
82+
}
83+
}
84+
result[i] = "PATH=" + strings.Join(resolved, string(filepath.ListSeparator))
85+
}
86+
return result
4987
}
5088

5189
// FilterDangerousEnv filters out dangerous environment variables from the given slice.

0 commit comments

Comments
 (0)