Stillroom is an encrypted vault for the Mac. It keeps files, notes, passwords and two-factor codes, and it never goes online: no account, no server, no sync, no update check, no analytics.
Writing "we never connect to the internet" on a website is easy. Keeping it true is harder, because one dependency, one convenience call or one well-meant "check for updates" is enough to break it, and nobody notices until someone runs Little Snitch. So the claim is checked by the build. If the app binary can reach the network, the build fails and there is nothing to ship.
The check
It's a short shell script that runs against the compiled binary, not the source:
#!/bin/bash
set -euo pipefail
BIN="${1:?usage: check-offline.sh <path to the Stillroom binary>}"
linked=$(otool -L "$BIN" | grep -E "/(Network|CFNetwork)\.framework/" || true)
imported=$(nm -u "$BIN" | grep -E "^_(socket|connect|getaddrinfo|gethostbyname|sendto|bind|listen)$|NSURLSession|URLSession|NSURLConnection|NSURLDownload|_nw_|NWConnection|NWListener|NWBrowser|CFSocket|CFStreamCreatePairWith|CFHost|NSNetService|SCNetworkReachability" || true)
if [ -n "$linked$imported" ]; then
echo "check-offline: $BIN can reach the network:" >&2
[ -n "$linked" ] && echo "$linked" >&2
[ -n "$imported" ] && echo "$imported" >&2
exit 1
fi
echo "check-offline: no networking code in $(basename "$BIN")"
Two tools do the work.
otool -L lists the dynamic libraries the binary links. If Network.framework or CFNetwork.framework shows up, something pulled it in, and the build stops.
nm -u lists the undefined symbols, the functions the binary expects the system to provide at runtime. That catches the calls themselves: BSD sockets (socket, connect, bind, listen, sendto), DNS (getaddrinfo, gethostbyname), URL loading (URLSession, NSURLSession, NSURLConnection, NSURLDownload), the Network framework (NWConnection, NWListener, NWBrowser, anything _nw_), and the older Core Foundation and Bonjour ways in (CFSocket, CFHost, CFStreamCreatePairWith…, NSNetService, SCNetworkReachability).
Checking the binary rather than grepping the source matters. A Swift package I add, or a framework that quietly depends on URLSession, shows up in the binary whether or not I ever read its code.
Where it runs
The app is a Swift package with no Xcode project, built by a script. That script runs the check right after it assembles the app bundle, and the release script calls the same build script before signing and notarizing. So there is no path to a notarized download that skips it. It's the same on a normal debug build, which means I find out the moment I add something that touches the network, not on release day.
The one exception
Stillroom can show emails you've stored, and an email is HTML, so the viewer is a WKWebView. WebKit is networking code by definition, so this is the one place the check can't speak for, and it gets its own guard instead:
- JavaScript off, and clicked links cancelled.
- A Content Security Policy that allows only
data:. - A WebKit content block list that allows only
data:andabout:. -
<link>tags are disarmed before the page is shown, because preconnect and prefetch hints open connections on their own and the page policy doesn't cover them.
And a test proves it. It loads a hostile email that points every kind of remote load it can think of at a small server running on the same Mac: images, srcset, stylesheets, @import, fonts, frames, scripts, media, forms, SVG, <base>, meta refresh, preconnect, prefetch. The test fails if the server sees a single connection.
That test earned its place. At one point WebKit rejected one of the block list rules silently ("disjunctions are not supported"), and pages went out with one lock fewer than I thought. With the block list missing, the test server sees two connections, so now the email is never shown unless the block list compiled.
This is also why URLRequest on its own is allowed through the check: the email viewer reads the address of a clicked link in order to cancel it.
What it doesn't prove
The check catches mistakes. It isn't a proof, and I'd rather say where it stops than have someone find it:
- Some Foundation calls can load from the network through frameworks the check doesn't look at.
Data(contentsOf:)given anhttps://URL will happily fetch it. The rule in the code is to never pass one of those a URL that didn't come from a file. - It says nothing about macOS itself. The system does what it does.
- It's about the app. The website is separate: it has Cloudflare's cookie-free analytics, and the checkout runs on Paddle.
What never going online costs
There's no sync, which is the point, but it means you move a vault between Macs with a backup on a disk. There's no update check, so updates are a download from the website when you choose, and an RSS feed if you want to hear about them. The licence is an Ed25519-signed key checked entirely on the Mac, with no activation server, no clock and no expiry.
Those were deliberate trade-offs, and the build check is what keeps them from being undone by accident later.
If you make software with a similar promise, I'd like to know how you enforce it: link-time checks like this, an entitlement-based approach, or tests that watch the network. The threat model and file format are public at stillroomvault.com.
Top comments (3)
Checking the compiled binary instead of the source is the right call. One gap worth closing: dlopen/dlsym. A dependency can load CFNetwork or look up socket() by name at runtime, and neither otool nor nm will see it, so flagging _dlopen, _dlsym and NSClassFromString in the same script at least makes that path deliberate. If the WKWebView still works without it, leaving the com.apple.security.network.client entitlement off under App Sandbox would also give you an OS-enforced backstop behind the build check.
Good catch, thanks. Right now the check only looks at what the binary links and imports, so a dlsym lookup by name would slip past it. The app does use dlsym once, to look up swift_demangle for readable crash stacks, so i cant ban it outright, but i can flag dlopen, dlsym and NSClassFromString and allow that one lookup by name. On the sandbox: its not sandboxed today, just hardened runtime, so theres no OS backstop behind the check yet. Thats a fair gap and its on the list
@vardum Added it. dlopen, dlsym and NSClassFromString now fail the build, apart from the one dlsym the compiler adds for OS version checks, and the check reads the disassembly to make sure nothing else calls it. The apps own dlsym is gone too, swift_demangle is linked directly now. Thanks for the catch