diff --git a/src/lib.zig b/src/lib.zig
index 576cfea..926b696 100644
--- a/src/lib.zig
+++ b/src/lib.zig
@@ -320,6 +320,9 @@ pub const os = struct {
     }
 
     const linux = struct {
+        pub const stdin = 0;
+        pub const stdout = 1;
+        pub const stderr = 2;
         pub const CPU_SETSIZE = 128;
         pub const cpu_set_t = [CPU_SETSIZE / @sizeOf(usize)]usize;
         pub const cpu_count_t = @Type(.{
@@ -450,6 +453,11 @@ pub const os = struct {
         extern "c" fn write(fd: system.FileDescriptor, pointer: [*]const u8, byte_count: usize) isize;
         extern "c" fn sched_getaffinity(pid: c_int, size: usize, set: *cpu_set_t) c_int;
         extern "c" fn mkdir(path: [*:0]const u8, mode: mode_t) c_int;
+        extern "c" fn pipe(pipe: *[2]i32) File.Descriptor;
+        extern "c" fn fork() Process.Descriptor;
+        extern "c" fn dup2(old: File.Descriptor, new: File.Descriptor) c_int;
+        extern "c" fn execve(path_name: [*:0]const u8, arguments: [*:null]const ?[*:0]const u8, environment: [*:null]const ?[*:0]const u8) c_int;
+        extern "c" fn waitpid(pid: Process.Descriptor, status: ?*u32, options: u32) Process.Descriptor;
 
         const mode_t = usize;
 
@@ -472,6 +480,34 @@ pub const os = struct {
 
             return result;
         }
+
+        pub const W = struct {
+            pub const NOHANG = 1;
+            pub const UNTRACED = 2;
+            pub const STOPPED = 2;
+            pub const EXITED = 4;
+            pub const CONTINUED = 8;
+            pub const NOWAIT = 0x1000000;
+
+            pub fn EXITSTATUS(s: u32) u8 {
+                return @as(u8, @intCast((s & 0xff00) >> 8));
+            }
+            pub fn TERMSIG(s: u32) u32 {
+                return s & 0x7f;
+            }
+            pub fn STOPSIG(s: u32) u32 {
+                return EXITSTATUS(s);
+            }
+            pub fn IFEXITED(s: u32) bool {
+                return TERMSIG(s) == 0;
+            }
+            pub fn IFSTOPPED(s: u32) bool {
+                return @as(u16, @truncate(((s & 0xffff) *% 0x10001) >> 8)) > 0x7f00;
+            }
+            pub fn IFSIGNALED(s: u32) bool {
+                return (s & 0xffff) -% 1 < 0xff;
+            }
+        };
     };
 
     const windows = struct {
@@ -534,7 +570,7 @@ pub const os = struct {
             .windows => @compileError("TODO"),
             else => {
                 return File{
-                    .fd = 1,
+                    .fd = posix.stdout,
                 };
             },
         };
@@ -545,7 +581,7 @@ pub const os = struct {
             .windows => @compileError("TODO"),
             else => {
                 return File{
-                    .fd = 2,
+                    .fd = posix.stderr,
                 };
             },
         };
@@ -563,6 +599,185 @@ pub const os = struct {
         var buffer: [4096]u8 = undefined;
         return arena.duplicate_string(absolute_path_stack(&buffer, relative_path));
     }
+
+    const ChildProcessStream = struct {
+        const Policy = enum {
+            inherit,
+            pipe,
+            ignore,
+        };
+    };
+
+    pub const RunChildProcessOptions = struct {
+        stdout: ChildProcessStream.Policy,
+        stderr: ChildProcessStream.Policy,
+        null_file_descriptor: ?File.Descriptor,
+    };
+
+    pub const RunChildProcessResult = struct {
+        stdout: []const u8,
+        stderr: []const u8,
+        kind: Kind,
+        code: u32,
+
+        pub fn is_successful(result: RunChildProcessResult) bool {
+            return result.kind == .exit and result.code == 0;
+        }
+
+        pub const Kind = enum {
+            exit,
+            signal,
+            stop,
+            unknown,
+        };
+    };
+
+    pub fn run_child_process(arena: *Arena, arguments: [*:null]const ?[*:0]const u8, environment_variables: [*:null]const ?[*:0]const u8, options: RunChildProcessOptions) RunChildProcessResult {
+        const null_fd: File.Descriptor = if (options.null_file_descriptor) |nullfd| nullfd else if (options.stdout == .ignore or options.stderr == .ignore) posix.open("/dev/null", .{ .ACCMODE = .WRONLY }) else undefined;
+
+        var stdout_pipe: [2]i32 = undefined;
+        var stderr_pipe: [2]i32 = undefined;
+
+        if (options.stdout == .pipe) {
+            if (posix.pipe(&stdout_pipe) == -1) {
+                @trap();
+            }
+        }
+
+        if (options.stderr == .pipe) {
+            if (posix.pipe(&stderr_pipe) == -1) {
+                @trap();
+            }
+        }
+
+        const pid = posix.fork();
+
+        switch (pid) {
+            -1 => @trap(),
+            // Child process
+            0 => {
+                switch (options.stdout) {
+                    .pipe => {
+                        _ = posix.close(stdout_pipe[0]);
+                        _ = posix.dup2(stdout_pipe[1], posix.stdout);
+                        _ = posix.close(stdout_pipe[1]);
+                    },
+                    .ignore => {
+                        _ = posix.dup2(null_fd, posix.stdout);
+                        _ = posix.close(null_fd);
+                    },
+                    .inherit => {},
+                }
+
+                switch (options.stderr) {
+                    .pipe => {
+                        _ = posix.close(stderr_pipe[0]);
+                        _ = posix.dup2(stderr_pipe[1], posix.stderr);
+                        _ = posix.close(stderr_pipe[1]);
+                    },
+                    .ignore => {
+                        _ = posix.dup2(null_fd, posix.stderr);
+                        _ = posix.close(null_fd);
+                    },
+                    .inherit => {},
+                }
+
+                const result = posix.execve(arguments[0].?, arguments, environment_variables);
+                if (result != 1) {
+                    unreachable;
+                }
+
+                @trap();
+            },
+            // Parent (~current) process
+            else => {
+                if (options.stdout == .pipe) {
+                    _ = posix.close(stdout_pipe[1]);
+                }
+
+                if (options.stderr == .pipe) {
+                    _ = posix.close(stderr_pipe[1]);
+                }
+
+                var stdout_buffer_slice: []u8 = &.{};
+                var stderr_buffer_slice: []u8 = &.{};
+                if (options.stdout == .pipe or options.stderr == .pipe) {
+                    const allocation_size = 1024 * 1024;
+                    const allocation = arena.allocate_bytes(2 * allocation_size, 1);
+
+                    var offset: u64 = 0;
+                    if (options.stdout == .pipe) {
+                        const stdout_buffer_offset = offset;
+                        offset += allocation_size;
+                        stdout_buffer_slice = allocation[stdout_buffer_offset..][0..allocation_size];
+                    }
+
+                    if (options.stderr == .pipe) {
+                        const stderr_buffer_offset = offset;
+                        offset += allocation_size;
+                        stderr_buffer_slice = allocation[stderr_buffer_offset..][0..allocation_size];
+                    }
+                }
+
+                if (options.stdout == .pipe) {
+                    const byte_count = posix.read(stdout_pipe[0], stdout_buffer_slice.ptr, stdout_buffer_slice.len);
+                    assert(byte_count >= 0);
+                    stdout_buffer_slice = stdout_buffer_slice[0..@intCast(byte_count)];
+                    _ = posix.close(stdout_pipe[0]);
+                }
+
+                if (options.stderr == .pipe) {
+                    const byte_count = posix.read(stderr_pipe[0], stderr_buffer_slice.ptr, stderr_buffer_slice.len);
+                    assert(byte_count >= 0);
+                    stderr_buffer_slice = stderr_buffer_slice[0..@intCast(byte_count)];
+                    _ = posix.close(stderr_pipe[0]);
+                }
+
+                var status: u32 = 0;
+                const waitpid_result = posix.waitpid(pid, &status, 0);
+
+                if (waitpid_result == pid) {
+                    const termination: struct {
+                        code: u32,
+                        kind: RunChildProcessResult.Kind,
+                    } = if (linux.W.IFEXITED(status)) .{
+                        .code = linux.W.EXITSTATUS(status),
+                        .kind = .exit,
+                    } else if (linux.W.IFSIGNALED(status)) .{
+                        .code = linux.W.TERMSIG(status),
+                        .kind = .signal,
+                    } else if (linux.W.IFSTOPPED(status)) .{
+                        .code = linux.W.STOPSIG(status),
+                        .kind = .stop,
+                    } else .{
+                        .code = 0,
+                        .kind = .unknown,
+                    };
+
+                    if (options.null_file_descriptor == null and system.fd_is_valid(null_fd)) {
+                        _ = posix.close(null_fd);
+                    }
+
+                    return .{
+                        .stdout = stdout_buffer_slice,
+                        .stderr = stderr_buffer_slice,
+                        .kind = termination.kind,
+                        .code = termination.code,
+                    };
+                } else if (waitpid_result == -1) {
+                    @trap();
+                } else {
+                    @trap();
+                }
+            },
+        }
+    }
+
+    const Process = struct {
+        descriptor: Descriptor,
+
+        const Descriptor = system.FileDescriptor;
+    };
 };
 
 pub const libc = struct {
@@ -2836,9 +3051,9 @@ pub const panic_struct = struct {
     }
 };
 
-pub export fn main(argc: c_int, argv: [*:null]const ?[*:0]const u8) callconv(.C) c_int {
+pub export fn main(argc: c_int, argv: [*:null]const ?[*:0]const u8, environment: [*:null]const ?[*:0]const u8) callconv(.C) c_int {
     enable_signal_handlers();
     const arguments: []const [*:0]const u8 = @ptrCast(argv[0..@intCast(argc)]);
-    @import("root").entry_point(arguments);
+    @import("root").entry_point(arguments, environment);
     return 0;
 }
diff --git a/src/main.zig b/src/main.zig
index b9d4105..719af61 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -100,7 +100,7 @@ fn compile_file(arena: *Arena, compile: Compile) converter.Options {
 
 const base_cache_dir = "bb-cache";
 
-pub fn entry_point(arguments: []const [*:0]const u8) void {
+pub fn entry_point(arguments: []const [*:0]const u8, environment: [*:null]const ?[*:0]const u8) void {
     lib.GlobalState.initialize();
     const arena = lib.global.arena;
 
@@ -135,12 +135,22 @@ pub fn entry_point(arguments: []const [*:0]const u8) void {
                         defer arena.restore(position);
 
                         const relative_file_path = arena.join_string(&.{ "tests/", name, ".bbb" });
-                        _ = compile_file(arena, .{
+                        const compile_result = compile_file(arena, .{
                             .relative_file_path = relative_file_path,
                             .build_mode = build_mode,
                             .has_debug_info = has_debug_info,
                             .silent = true,
                         });
+
+                        const result = lib.os.run_child_process(arena, &.{compile_result.executable}, environment, .{
+                            .stdout = .pipe,
+                            .stderr = .pipe,
+                            .null_file_descriptor = null,
+                        });
+
+                        if (!result.is_successful()) {
+                            @trap();
+                        }
                     }
                 }
             }