From 76bc490a74a97286961d8bad4dff619809af694f Mon Sep 17 00:00:00 2001 From: rustytrees Date: Wed, 12 Aug 2026 17:56:46 -0400 Subject: [PATCH 1/2] fix(sandbox): confine child file reads Deny file contents under mutable and user-data roots while preserving the metadata and system runtime reads macOS processes need. Grant formula data and generated Ruby scripts explicitly, and start system Ruby without scanning host-installed gems. --- src/core/dsl/builtins/process.zig | 13 ++++-- src/core/sandbox/macos.zig | 69 +++++++++++++++++++++++++++---- tests/sandbox_macos_test.zig | 60 +++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 12 deletions(-) diff --git a/src/core/dsl/builtins/process.zig b/src/core/dsl/builtins/process.zig index 191b33ba..6ee6960a 100644 --- a/src/core/dsl/builtins/process.zig +++ b/src/core/dsl/builtins/process.zig @@ -789,8 +789,12 @@ test "system drains child output larger than a pipe buffer" { var lio: std.Io.Threaded = .init(alloc, .{}); defer lio.deinit(); - const fixture = try std.fmt.allocPrint(alloc, "/tmp/malt_bigout_{d}", .{std.c.getpid()}); - defer std.Io.Dir.cwd().deleteFile(lio.io(), fixture) catch {}; + const root = try std.fmt.allocPrint(alloc, "/tmp/malt_bigout_{d}", .{std.c.getpid()}); + std.Io.Dir.cwd().deleteTree(lio.io(), root) catch {}; + defer std.Io.Dir.cwd().deleteTree(lio.io(), root) catch {}; + const cellar = try std.fmt.allocPrint(alloc, "{s}/Cellar/foo/1.0", .{root}); + try std.Io.Dir.cwd().createDirPath(lio.io(), cellar); + const fixture = try std.fmt.allocPrint(alloc, "{s}/payload", .{root}); { const f = try std.Io.Dir.createFileAbsolute(lio.io(), fixture, .{ .truncate = true }); defer f.close(lio.io()); @@ -802,7 +806,10 @@ test "system drains child output larger than a pipe buffer" { } var cap = try FdCapture.start(alloc, lio.io(), std.c.STDOUT_FILENO, "big"); - _ = system(sanitizeTestCtx(alloc, lio.io()), null, &.{ + var ctx = sanitizeTestCtx(alloc, lio.io()); + ctx.cellar_path = cellar; + ctx.malt_prefix = root; + _ = system(ctx, null, &.{ .{ .string = "/bin/cat" }, .{ .string = fixture }, }) catch {}; diff --git a/src/core/sandbox/macos.zig b/src/core/sandbox/macos.zig index d848ee6c..586e1798 100644 --- a/src/core/sandbox/macos.zig +++ b/src/core/sandbox/macos.zig @@ -62,6 +62,9 @@ pub fn validatePathForProfile(p: []const u8) SandboxError!void { /// initialisers only — every other fenced child stays IPC-free. pub const ProfileOpts = struct { allow_ipc: bool = false, + /// Individual files needed by this spawn, such as the generated Ruby + /// wrapper. These are literal rules, not directory grants. + read_files: []const []const u8 = &.{}, }; /// Render the deny-by-default SCL profile; writes limited to `cellar_path` @@ -87,7 +90,6 @@ pub fn renderRubyProfile( \\(allow sysctl-read) \\(allow mach-lookup) \\(allow iokit-open) - \\(allow file-read*) \\(deny network*) \\(allow file-write-data \\ (regex #"^/dev/(null|dtracehelper|tty|stdout|stderr)$") @@ -98,12 +100,64 @@ pub fn renderRubyProfile( ; buf.appendSlice(allocator, header) catch return SandboxError.ProfileBuildFailed; + for (opts.read_files) |path| try validatePathForProfile(path); + + var aw: std.Io.Writer.Allocating = .fromArrayList(allocator, &buf); + const w = &aw.writer; + // macOS executables need read operations beyond file contents during + // startup. Keep those operations available, then deny file contents in + // every mutable or user-data root. More-specific grants below restore + // contents only for Malt and a small set of platform runtime paths. + w.writeAll( + \\(allow file-read*) + \\(deny file-read-data + ) catch return SandboxError.ProfileBuildFailed; + for ([_][]const u8{ + "/Applications", + "/Library", + "/Users", + "/Volumes", + "/cores", + "/dev", + "/opt", + "/private", + "/usr/local", + "/System/Volumes/Data", + }) |root| w.print("\n (subpath \"{s}\")", .{root}) catch + return SandboxError.ProfileBuildFailed; + w.writeAll(")\n(allow file-read-data") catch return SandboxError.ProfileBuildFailed; + for ([_][]const u8{ + "/Library/Apple", + "/private/var/db/timezone", + "/dev/null", + "/dev/random", + "/dev/urandom", + "/dev/zero", + "/dev/tty", + }) |path| w.print("\n (literal \"{s}\")", .{path}) catch + return SandboxError.ProfileBuildFailed; + var cellar_real_buf: [std.fs.max_path_bytes]u8 = undefined; + var prefix_real_buf: [std.fs.max_path_bytes]u8 = undefined; + writeCellarRule(w, cellar_path) catch return SandboxError.ProfileBuildFailed; + if (resolvedForProfile(cellar_path, &cellar_real_buf)) |real| + writeCellarRule(w, real) catch return SandboxError.ProfileBuildFailed; + writeCellarRule(w, malt_prefix) catch return SandboxError.ProfileBuildFailed; + if (resolvedForProfile(malt_prefix, &prefix_real_buf)) |real| + writeCellarRule(w, real) catch return SandboxError.ProfileBuildFailed; + for (opts.read_files) |path| { + w.print("\n (literal \"{s}\")", .{path}) catch return SandboxError.ProfileBuildFailed; + var real_buf: [std.fs.max_path_bytes]u8 = undefined; + if (resolvedForProfile(path, &real_buf)) |real| + w.print("\n (literal \"{s}\")", .{real}) catch return SandboxError.ProfileBuildFailed; + } + w.writeAll(")\n") catch return SandboxError.ProfileBuildFailed; + // Database initialisers (postgres bootstrap) allocate SysV/POSIX shared // memory + SysV semaphores; without these grants initdb dies in // shmget/semctl. Scoped to `init_data_dir` so no other fenced child // (DSL system, cache-regen tools, Ruby fallback) gets IPC access. if (opts.allow_ipc) { - buf.appendSlice(allocator, + w.writeAll( \\(allow ipc-sysv-shm) \\(allow ipc-sysv-sem) \\(allow ipc-posix-shm) @@ -111,16 +165,11 @@ pub fn renderRubyProfile( ) catch return SandboxError.ProfileBuildFailed; } - var aw: std.Io.Writer.Allocating = .fromArrayList(allocator, &buf); - const w = &aw.writer; w.writeAll("(allow file-write*") catch return SandboxError.ProfileBuildFailed; // The kernel matches subpath filters against resolved vnode paths; // a symlinked root (macOS /tmp → /private/tmp) needs its resolved // form granted too or writes under it are silently denied. - var cellar_real_buf: [std.fs.max_path_bytes]u8 = undefined; - var prefix_real_buf: [std.fs.max_path_bytes]u8 = undefined; - writeCellarRule(w, cellar_path) catch return SandboxError.ProfileBuildFailed; if (resolvedForProfile(cellar_path, &cellar_real_buf)) |real| writeCellarRule(w, real) catch return SandboxError.ProfileBuildFailed; @@ -264,11 +313,13 @@ pub fn runRubySandboxed( ) SandboxError!u8 { if (builtin.os.tag != .macos) return SandboxError.SandboxUnsupported; - const profile = try renderRubyProfile(allocator, cellar_path, malt_prefix, .{}); + const profile = try renderRubyProfile(allocator, cellar_path, malt_prefix, .{ + .read_files = &.{script_path}, + }); defer allocator.free(profile); const argv = [_][]const u8{ - "/usr/bin/sandbox-exec", "-p", profile, ruby_path, script_path, + "/usr/bin/sandbox-exec", "-p", profile, ruby_path, "--disable-gems", script_path, }; const argv_z = try buildArgv(allocator, argv[0..]); defer freeArgv(allocator, argv_z); diff --git a/tests/sandbox_macos_test.zig b/tests/sandbox_macos_test.zig index dca6ad3c..0550623d 100644 --- a/tests/sandbox_macos_test.zig +++ b/tests/sandbox_macos_test.zig @@ -43,6 +43,9 @@ test "renderRubyProfile deny-by-default, network denied, cellar + prefix subpath try testing.expect(std.mem.indexOf(u8, profile, "(deny default)") != null); try testing.expect(std.mem.indexOf(u8, profile, "(deny network*)") != null); try testing.expect(std.mem.indexOf(u8, profile, "(allow file-read*)") != null); + try testing.expect(std.mem.indexOf(u8, profile, "(deny file-read-data") != null); + try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/Users\")") != null); + try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/System/Volumes/Data\")") != null); try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/opt/malt/Cellar/foo/1.0\")") != null); try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/opt/malt/etc\")") != null); try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/opt/malt/var\")") != null); @@ -100,6 +103,63 @@ test "sandbox_path restricts to system directories only" { try testing.expectEqualStrings("/usr/bin:/bin:/usr/sbin:/sbin", sandbox.sandbox_path); } +test "sandbox profile refuses copying a source from outside the prefix" { + if (builtin.os.tag != .macos) return error.SkipZigTest; + try test_io.skipIfNoSubprocess(); + + const root = try test_io.uniqueTempPath(testing.allocator, "sandbox_macos", "read_source"); + defer testing.allocator.free(root); + test_io.deleteTreeAbsolute(std.Options.debug_io, root) catch {}; + defer test_io.deleteTreeAbsolute(std.Options.debug_io, root) catch {}; + + const prefix = try std.fmt.allocPrint(testing.allocator, "{s}/prefix", .{root}); + defer testing.allocator.free(prefix); + const keg = try std.fmt.allocPrint(testing.allocator, "{s}/Cellar/foo/1.0", .{prefix}); + defer testing.allocator.free(keg); + const share = try std.fmt.allocPrint(testing.allocator, "{s}/share", .{prefix}); + defer testing.allocator.free(share); + const victim = try std.fmt.allocPrint(testing.allocator, "{s}/private", .{root}); + defer testing.allocator.free(victim); + const leaked = try std.fmt.allocPrint(testing.allocator, "{s}/leaked", .{share}); + defer testing.allocator.free(leaked); + try test_io.cwd().createDirPath(std.Options.debug_io, keg); + try test_io.cwd().createDirPath(std.Options.debug_io, share); + { + const f = try test_io.createFileAbsolute(std.Options.debug_io, victim, .{}); + defer f.close(std.Options.debug_io); + try f.writeStreamingAll(std.Options.debug_io, "PRIVATE"); + } + + const profile = try sandbox.renderRubyProfile(testing.allocator, keg, prefix, .{}); + defer testing.allocator.free(profile); + const argv = [_][]const u8{ + "/usr/bin/sandbox-exec", "-p", profile, "/bin/cp", victim, leaked, + }; + const argv_z = try sandbox.buildArgv(testing.allocator, &argv); + defer sandbox.freeArgv(testing.allocator, argv_z); + const envp = try sandbox.buildEnvp(testing.allocator, .{ + .home = prefix, + .path = sandbox.sandbox_path, + .malt_prefix = prefix, + .tmpdir = prefix, + }); + defer sandbox.freeEnvp(testing.allocator, envp); + + const sink = test_io.testSink(); + const exit_code = try sandbox.spawnFilteredWithHooks( + argv_z, + envp, + .{}, + .{ .out = sink.handle, .err = sink.handle }, + .{}, + ); + try testing.expectError( + error.FileNotFound, + test_io.accessAbsolute(std.Options.debug_io, leaked, .{}), + ); + try testing.expect(exit_code != 0); +} + test "std.posix.rlimit_resource tags resolve on macOS for CPU/FSIZE/AS" { // Pin: the three rlimit tags applyRlimits relies on must round-trip // through the kernel on macOS. If a stdlib rename ever loses one of From 0eece81c724587c5593ff2498b1a5adb0fc35822 Mon Sep 17 00:00:00 2001 From: rustytrees Date: Thu, 13 Aug 2026 06:19:22 -0400 Subject: [PATCH 2/2] fix(sandbox): allow standard font reads Formula tools such as fontconfig scan system and per-user font directories during post-install. Keep those directories readable without opening the rest of HOME. Pass HOME to each sandbox profile and cover both the allowed font read and a denied sibling file. --- src/core/dsl/builtins/process.zig | 8 +++-- src/core/post_install_steps.zig | 52 ++++++++++++++++++++++++++++++- src/core/sandbox/macos.zig | 20 ++++++++++++ tests/sandbox_macos_test.zig | 22 +++++++++++-- 4 files changed, 97 insertions(+), 5 deletions(-) diff --git a/src/core/dsl/builtins/process.zig b/src/core/dsl/builtins/process.zig index 6ee6960a..cb5176f5 100644 --- a/src/core/dsl/builtins/process.zig +++ b/src/core/dsl/builtins/process.zig @@ -94,7 +94,9 @@ pub fn system(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError!Value { // The lint above waves bare/system-dir argv0 through; the real write // containment is the sandbox-exec fence wrapping the spawn (parity with // the --use-system-ruby path). - const spawn_argv = sandbox.fenceArgv(ctx.allocator, argv_slice, ctx.cellar_path, ctx.malt_prefix, .{}) catch |e| switch (e) { + const spawn_argv = sandbox.fenceArgv(ctx.allocator, argv_slice, ctx.cellar_path, ctx.malt_prefix, .{ + .home = std.process.Environ.getPosix(ctx.environ, "HOME"), + }) catch |e| switch (e) { error.OutOfMemory => return BuiltinError.OutOfMemory, error.PathSandboxViolation => return BuiltinError.PathSandboxViolation, }; @@ -291,7 +293,9 @@ pub fn safePopenRead(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError! // capturing stdout must not be a way to spawn unconfined. sandbox.validateArgv(argv_slice, ctx.cellar_path, ctx.malt_prefix) catch return BuiltinError.PathSandboxViolation; - const spawn_argv = sandbox.fenceArgv(ctx.allocator, argv_slice, ctx.cellar_path, ctx.malt_prefix, .{}) catch |e| switch (e) { + const spawn_argv = sandbox.fenceArgv(ctx.allocator, argv_slice, ctx.cellar_path, ctx.malt_prefix, .{ + .home = std.process.Environ.getPosix(ctx.environ, "HOME"), + }) catch |e| switch (e) { error.OutOfMemory => return BuiltinError.OutOfMemory, error.PathSandboxViolation => return BuiltinError.PathSandboxViolation, }; diff --git a/src/core/post_install_steps.zig b/src/core/post_install_steps.zig index fa8c1cb9..327a1323 100644 --- a/src/core/post_install_steps.zig +++ b/src/core/post_install_steps.zig @@ -10,6 +10,7 @@ //! router downgrades to the loud partial-skip envelope. const std = @import("std"); +const builtin = @import("builtin"); const sandbox = @import("dsl/sandbox.zig"); const sandbox_macos = @import("sandbox/macos.zig"); const fallback_log = @import("dsl/fallback_log.zig"); @@ -1181,7 +1182,9 @@ fn spawnFenced(ctx: StepsCtx, argv: []const []const u8, env_map: ?*const std.pro logViolation(ctx, argv[0]); return false; }; - const fenced = sandbox.fenceArgv(ctx.allocator, argv, ctx.keg_path, ctx.prefix, opts) catch { + var profile_opts = opts; + profile_opts.home = std.process.Environ.getPosix(ctx.environ, "HOME"); + const fenced = sandbox.fenceArgv(ctx.allocator, argv, ctx.keg_path, ctx.prefix, profile_opts) catch { logViolation(ctx, argv[0]); return false; }; @@ -2556,6 +2559,53 @@ test "run executes the formula's own helper with its arguments expanded" { try testing.expect(dirExists(h.io, try std.fmt.allocPrint(a, "{s}/var/lib/dbus", .{h.prefix}))); } +test "run can read a standard user font without opening the rest of HOME" { + if (builtin.os.tag != .macos) return error.SkipZigTest; + + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + var h = try TestHarness.init(); + defer h.deinit(); + h.io = threaded.io(); + const a = h.arena.allocator(); + + const home = try std.fmt.allocPrint(a, "{s}_home", .{h.prefix}); + defer std.Io.Dir.cwd().deleteTree(h.io, home) catch {}; + const fonts = try std.fmt.allocPrint(a, "{s}/Library/Fonts", .{home}); + const source = try std.fmt.allocPrint(a, "{s}/Probe.ttf", .{fonts}); + const copied = try std.fmt.allocPrint(a, "{s}/share/Probe.ttf", .{h.prefix}); + try std.Io.Dir.cwd().createDirPath(h.io, fonts); + try std.Io.Dir.cwd().createDirPath(h.io, try std.fmt.allocPrint(a, "{s}/share", .{h.prefix})); + { + const f = try std.Io.Dir.createFileAbsolute(h.io, source, .{}); + defer f.close(h.io); + try f.writeStreamingAll(h.io, "FONT"); + } + + const libexec = try std.fmt.allocPrint(a, "{s}/libexec", .{h.keg}); + try std.Io.Dir.cwd().createDirPath(h.io, libexec); + try std.Io.Dir.symLinkAbsolute(h.io, "/bin/cp", try std.fmt.allocPrint(a, "{s}/copy-font", .{libexec}), .{}); + + const home_var = try std.fmt.allocPrintSentinel(a, "HOME={s}", .{home}, 0); + var env_entries = [_:null]?[*:0]const u8{home_var.ptr}; + h.environ = .{ .block = .{ .slice = &env_entries } }; + + const steps = try std.fmt.allocPrint( + a, + \\[{{"type":"run","command":{{"base":"libexec","path":"copy-font"}}, + \\ "args":["{s}","{s}"]}}] + , + .{ source, copied }, + ); + try testing.expect(execute(h.ctx(), try testFormulaJson(&h, steps))); + try testing.expect(!h.flog.hasErrors()); + const copied_file = try std.Io.Dir.openFileAbsolute(h.io, copied, .{}); + defer copied_file.close(h.io); + var buf: [4]u8 = undefined; + const n = try copied_file.readPositionalAll(h.io, &buf, 0); + try testing.expectEqualStrings("FONT", buf[0..n]); +} + test "run refuses the execution fields malt does not honour" { // Compaction drops defaults, so a key that survives into the JSON is one // the formula meant. Running without honouring it would be a truncated diff --git a/src/core/sandbox/macos.zig b/src/core/sandbox/macos.zig index 586e1798..ec386af4 100644 --- a/src/core/sandbox/macos.zig +++ b/src/core/sandbox/macos.zig @@ -62,6 +62,10 @@ pub fn validatePathForProfile(p: []const u8) SandboxError!void { /// initialisers only — every other fenced child stays IPC-free. pub const ProfileOpts = struct { allow_ipc: bool = false, + /// Formula tools such as fontconfig legitimately scan the standard user + /// font directories. The renderer grants only those descendants, never + /// the rest of HOME. + home: ?[]const u8 = null, /// Individual files needed by this spawn, such as the generated Ruby /// wrapper. These are literal rules, not directory grants. read_files: []const []const u8 = &.{}, @@ -77,6 +81,7 @@ pub fn renderRubyProfile( ) SandboxError![]const u8 { try validatePathForProfile(cellar_path); try validatePathForProfile(malt_prefix); + if (opts.home) |home| try validatePathForProfile(home); var buf: std.ArrayList(u8) = .empty; errdefer buf.deinit(allocator); @@ -136,14 +141,21 @@ pub fn renderRubyProfile( "/dev/tty", }) |path| w.print("\n (literal \"{s}\")", .{path}) catch return SandboxError.ProfileBuildFailed; + w.writeAll("\n (subpath \"/Library/Fonts\")") catch return SandboxError.ProfileBuildFailed; var cellar_real_buf: [std.fs.max_path_bytes]u8 = undefined; var prefix_real_buf: [std.fs.max_path_bytes]u8 = undefined; + var home_real_buf: [std.fs.max_path_bytes]u8 = undefined; writeCellarRule(w, cellar_path) catch return SandboxError.ProfileBuildFailed; if (resolvedForProfile(cellar_path, &cellar_real_buf)) |real| writeCellarRule(w, real) catch return SandboxError.ProfileBuildFailed; writeCellarRule(w, malt_prefix) catch return SandboxError.ProfileBuildFailed; if (resolvedForProfile(malt_prefix, &prefix_real_buf)) |real| writeCellarRule(w, real) catch return SandboxError.ProfileBuildFailed; + if (opts.home) |home| { + writeHomeFontRules(w, home) catch return SandboxError.ProfileBuildFailed; + if (resolvedForProfile(home, &home_real_buf)) |real| + writeHomeFontRules(w, real) catch return SandboxError.ProfileBuildFailed; + } for (opts.read_files) |path| { w.print("\n (literal \"{s}\")", .{path}) catch return SandboxError.ProfileBuildFailed; var real_buf: [std.fs.max_path_bytes]u8 = undefined; @@ -185,6 +197,13 @@ fn writeCellarRule(w: *std.Io.Writer, root: []const u8) !void { try w.print("\n (subpath \"{s}\")", .{root}); } +fn writeHomeFontRules(w: *std.Io.Writer, home: []const u8) !void { + const root = std.mem.trimEnd(u8, home, "/"); + if (root.len == 0) return; + for ([_][]const u8{ "Library/Fonts", ".fonts", ".local/share/fonts" }) |sub| + try w.print("\n (subpath \"{s}/{s}\")", .{ root, sub }); +} + fn writePrefixRules(w: *std.Io.Writer, prefix: []const u8) !void { // `/lib` is the cross-formula symlink farm — granting all of it // would let one formula's post-install overwrite another's dylib. The @@ -314,6 +333,7 @@ pub fn runRubySandboxed( if (builtin.os.tag != .macos) return SandboxError.SandboxUnsupported; const profile = try renderRubyProfile(allocator, cellar_path, malt_prefix, .{ + .home = env.home, .read_files = &.{script_path}, }); defer allocator.free(profile); diff --git a/tests/sandbox_macos_test.zig b/tests/sandbox_macos_test.zig index 0550623d..377ace9f 100644 --- a/tests/sandbox_macos_test.zig +++ b/tests/sandbox_macos_test.zig @@ -72,6 +72,21 @@ test "renderRubyProfile grants IPC only under allow_ipc" { try testing.expect(std.mem.indexOf(u8, profile, "(allow ipc-sysv-sem)") != null); } +test "renderRubyProfile grants standard font directories but not all of HOME" { + const profile = try sandbox.renderRubyProfile( + testing.allocator, + "/opt/malt/Cellar/fontconfig/2.18.3", + "/opt/malt", + .{ .home = "/test-home" }, + ); + defer testing.allocator.free(profile); + try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/Library/Fonts\")") != null); + try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/test-home/Library/Fonts\")") != null); + try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/test-home/.fonts\")") != null); + try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/test-home/.local/share/fonts\")") != null); + try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/test-home\")") == null); +} + test "renderRubyProfile refuses unsafe cellar path" { try testing.expectError( error.UnsafePath, @@ -118,19 +133,22 @@ test "sandbox profile refuses copying a source from outside the prefix" { defer testing.allocator.free(keg); const share = try std.fmt.allocPrint(testing.allocator, "{s}/share", .{prefix}); defer testing.allocator.free(share); - const victim = try std.fmt.allocPrint(testing.allocator, "{s}/private", .{root}); + const home = try std.fmt.allocPrint(testing.allocator, "{s}/home", .{root}); + defer testing.allocator.free(home); + const victim = try std.fmt.allocPrint(testing.allocator, "{s}/private", .{home}); defer testing.allocator.free(victim); const leaked = try std.fmt.allocPrint(testing.allocator, "{s}/leaked", .{share}); defer testing.allocator.free(leaked); try test_io.cwd().createDirPath(std.Options.debug_io, keg); try test_io.cwd().createDirPath(std.Options.debug_io, share); + try test_io.cwd().createDirPath(std.Options.debug_io, home); { const f = try test_io.createFileAbsolute(std.Options.debug_io, victim, .{}); defer f.close(std.Options.debug_io); try f.writeStreamingAll(std.Options.debug_io, "PRIVATE"); } - const profile = try sandbox.renderRubyProfile(testing.allocator, keg, prefix, .{}); + const profile = try sandbox.renderRubyProfile(testing.allocator, keg, prefix, .{ .home = home }); defer testing.allocator.free(profile); const argv = [_][]const u8{ "/usr/bin/sandbox-exec", "-p", profile, "/bin/cp", victim, leaked,