Airlock

About Airlock

Project page: GitHub - MaximKotliar/Airlock: macOS fork-based process isolation for Swift · GitHub

There's no try you can wrap around a segfault. When a parser walks off the end of an attacker-controlled buffer, or a C library calls abort(), the whole process goes with it — and on macOS the usual fix is to stand up an XPC helper: a separate target, a plist service definition, Mach ports, an IPC protocol. That's the right call for a long-lived service. It's a lot of ceremony when all you wanted was to run one risky function somewhere else and get an error back if it dies.

I wanted the isolation without the ceremony, so I built Airlock. You define a task as a struct whose Input/Output are any Codable type:

struct ThumbnailTask: AirlockTask {
    struct Input: Codable {
        var sourceURL: URL
        var maxPixelSize: Int
    }
    struct Output: Codable {
        var pngData: Data
        var pixelSize: CGSize
    }

    // Runs in the isolated child. A crash in here can't reach the parent.
    static func main(_ input: Input) throws -> Output {
        let image = try ImageRenderer.load(input.sourceURL)
        return Output(pngData: image.encodePNG(max: input.maxPixelSize),
                      pixelSize: image.size)
    }
}

Register the task once at startup, then call run wherever you need it:

Airlock.register(ThumbnailTask.self)
Airlock.engage()

let output = try Airlock.run(ThumbnailTask.self,
                             input: .init(sourceURL: url, maxPixelSize: 512))

run is synchronous — it blocks until the child exits and either hands you the decoded Output or throws. The crash you couldn't catch becomes an ordinary AirlockError you handle like any other:

do {
    let output = try Airlock.run(ThumbnailTask.self, input: input)
    use(output)
} catch let error as AirlockError {
    switch error {
    case .childExitedAbnormally(let status):   // SIGABRT, EXC_BAD_ACCESS, ...
        log("isolated task crashed: \(status)")
    case .childExitCode(let code):             // e.g. 4 = task main() threw
        log("child exited with \(code)")
    case .decodingFailed(let underlying):
        log("couldn't decode output: \(underlying)")
    default:
        throw error                            // .spawnFailed, etc.
    }
}

The mechanism is the interesting part. Once you've ruled out XPC and a separate helper tool as too heavy, the tempting lightweight primitive is fork() — but on macOS that's a trap: only the calling thread survives into the child, so any lock another thread held (the allocator's, say) stays stuck forever, and the Swift runtime can't survive it. So the preferred path doesn't fork. Instead it re-execs the current binary via posix_spawn, and a C-level __attribute__((constructor)) catches the child before main() runs and diverts it into the matching task. Because the child is a fresh process, the full Swift runtime is live inside it: threads, ARC, libdispatch, all normal, none of the classic post-fork hazards. Input and output are Codable values that travel through an anonymous shared-memory region — the parent shm_opens it, unlinks the name before the spawn, and passes only the bare fd, so nothing identifying ever lands in argv or on disk. If the child takes a SIGABRT or an EXC_BAD_ACCESS, run throws an AirlockError and the parent keeps going.

There's also a runUnsafely { } closure API that uses real fork() for when you don't want to define a task type. It is honestly unsafe in the textbook sense: only the forking thread survives, any lock another thread held stays locked forever, and ARC/allocators aren't safe to lean on. The ideal shape there is a C library behind a value-type wrapper on an ARC-free path. The README spells out the boundaries; if they don't fit, the posix_spawn path is the one to use.

Status. Early but real — it's at 0.1.0, the safe run API is the part I'd actually reach for, and I'm still actively working on it. No corporate sponsor, small user base; I'm putting it here because the Swift systems crowd is exactly who can tell me where it's wrong. It's 0BSD-licensed, so there are no strings on using or vendoring it.

Priorities. Getting isolation working with the least ceremony a caller can manage, and being completely honest about the safety boundary between the two APIs.

What I'm hoping for from the community. Mostly feedback, and a second set of eyes on the things I've probably overlooked — limitations, edge cases, and assumptions in the design that don't hold up once someone who knows these layers looks closely. Nothing more concrete than that yet. Issues and PRs welcome.

6 Likes

Interesting, congrats!

How do you make sure airlock_parse_argv is the only or at least first __attribute__((constructor)) to run in the child process? If I remember correctly, there is no defined order, so any initializer (from any library) can run before the proper “task” runs and leave the process in an uncertain state.

2 Likes

Thanks for the interest!

Good question — but airlock_parse_argv never runs the task, so its order doesn't matter. All it does is copy --airlock <fd> <size> out of argv into a few static globals. No Swift runtime, no Foundation, no other library touched. It depends on nothing and nothing depends on it, so first-or-last in the init order is irrelevant.

The real work — decode input, run T.main, encode output — happens later in register() / engage(), which you call at the top of main(). By then every constructor has finished and the dyld lock is released. Running task code inside a constructor would mean executing arbitrary user logic under that lock, where GCD, dlopen, or lazy framework init can deadlock. Keeping the constructor trivial is exactly how i dodge the ordering problem you're describing.

It's not totally free, though. Every initializer in the binary re-runs in the freshly spawned child, and some of that startup work behaves differently the second time around — that's the real source of surprises, not our constructor. So call register() / engage() as early as possible in main(): the sooner you divert and exit, the less unrelated startup the child does before handing off.

That said, I'd genuinely welcome ideas on getting in earlier or more cleanly than a constructor — if there's a better hook for this, I'd love to hear it. :slightly_smiling_face:

1 Like

I see, thanks for the explanation.

I think it would make sense to clearly document this behavior. Any code in the executable that runs before main will run before the actual workload is performed. There are a lot of libraries that use C++ static initializers or __attribute__((constructor)) to perform their setup. If those have any side-effects this could affect how (or if) the workload is executed as intended.

Just a caveat to keep in mind.

1 Like