How pledge Works
pledge(2) restricts a process to a named set of syscall groups called promises. The promise strings are human-readable: "stdio", "rpath", "wpath", "cpath", "inet", "dns", "exec", "proc", "id", and about 30 others. Once you call pledge with a set of promises, the process cannot expand that set - it can only narrow it further with subsequent pledge calls.
The kernel maps each promise to a bitmap of allowed syscalls. When a restricted process calls a syscall not in any active promise group, the kernel sends SIGABRT, logs "pledge violation" with the process name and the syscall number to /var/log/messages, and the process dies. No graceful handling, no recovery. This is intentional.
The second argument to pledge is execpromises, which controls what promises child processes inherit after exec. Passing NULL here means children inherit the current promise set. Passing an empty string means children get no promises at all - useful when exec-ing untrusted binaries.
/* minimal pledge call - stdio only */
#include
int main(void) {
if (pledge("stdio", NULL) == -1)
err(1, "pledge");
/* anything beyond stdio now kills the process */
printf("running with only stdio promises\n");
return 0;
}
How unveil Works
unveil(2) restricts filesystem visibility to an explicit allowlist of paths. Before any unveil call, the process has full filesystem access (subject to normal DAC permissions). After the first unveil call, the process can only see paths you have explicitly revealed. Calling unveil(NULL, NULL) locks the allowlist - no further paths can be added.
The permissions argument is a string containing one or more of: "r" (read), "w" (write), "x" (execute), "c" (create). These are independent of the underlying file permissions - you can unveil a path with "r" even if the file is world-writable, and the process will only be able to read it.
Paths are matched by prefix. Unveiling "/etc" with "r" gives read access to everything under /etc. Unveiling "/etc/myapp.conf" with "r" gives access only to that specific file. There is no glob syntax - you work with literal paths and directory prefixes.
/* unveil a config file read-only, then lock the list */
#include
int main(void) {
if (unveil("/etc/myapp.conf", "r") == -1)
err(1, "unveil");
if (unveil(NULL, NULL) == -1)
err(1, "unveil lock");
if (pledge("stdio rpath", NULL) == -1)
err(1, "pledge");
/* open("/etc/passwd") will now fail with ENOENT */
return 0;
}
Combining pledge and unveil: Order Matters
The correct pattern is: unveil the paths you need, lock unveil, then call pledge. If you pledge first and drop filesystem-related promises before you finish unveil calls, you may hit pledge violations when calling unveil itself. unveil(2) requires the "unveil" promise to be active after the initial pledge call - or rather, unveil is in the implicit set before you call pledge at all.
A production-grade pattern for a network daemon that reads a config file and writes to a log directory looks like this. We tested this on OpenBSD 7.5 running on amd64.
After locking unveil and pledging, attempting to open any path not in the unveil list returns ENOENT, not EACCES. This is deliberate - it leaks no information about whether the path exists. From an attacker's perspective operating inside a compromised process, the filesystem appears to contain only what you unveiled.
#include
#include
#include
int main(void) {
/* Phase 1: unveil required paths */
if (unveil("/etc/myapp", "r") == -1) err(1, "unveil config");
if (unveil("/var/log/myapp", "rw") == -1) err(1, "unveil log");
if (unveil("/tmp", "rwc") == -1) err(1, "unveil tmp");
if (unveil(NULL, NULL) == -1) err(1, "unveil lock");
/* Phase 2: pledge syscall groups */
if (pledge("stdio rpath wpath cpath inet dns", NULL) == -1)
err(1, "pledge");
/* from here: filesystem is /etc/myapp, /var/log/myapp, /tmp only */
/* from here: syscalls restricted to the five promise groups */
return 0;
}
Testing Enforcement Without Writing C
You do not need to modify source code to test whether pledge violations fire correctly. OpenBSD ships ktrace(1) which traces syscalls, and you can verify violations via /var/log/messages directly.
To confirm a violation was logged after a crash:
grep pledge /var/log/messages
Output on our test server running OpenBSD 7.5 looked like:
Aug 15 14:22:01 testbox /bsd: pledge "execve", syscall 59
To trace a process in real time with ktrace and look at what syscalls it makes before you apply pledge, run the program under ktrace then review with kdump:
The kdump output gives you the exact syscall names. Map those to pledge promise groups using the pledge(2) man page table - it lists every syscall under its promise group. This workflow is how you determine the minimal promise set before writing the pledge call.
# trace a running binary
ktrace -tc /usr/local/bin/myapp
# review the trace
kdump | grep CALL | awk '{print $4}' | sort -u
# check for pledge violations in real time
tail -f /var/log/messages | grep pledge
Applying pledge to Existing Programs: The Chromium Pattern
OpenBSD's Chromium port uses pledge and unveil aggressively. The base browser process pledges "stdio rpath prot_exec", renderer subprocesses pledge "stdio", and the GPU process pledges "stdio drm". Each process gets only what it needs. If a renderer is compromised, it cannot make network calls, cannot exec, cannot read /etc.
The technique used in the ports tree is to call pledge early in main() with a broad set, then narrow it after initialization. This two-phase approach handles the common case where startup code needs capabilities (reading config, opening sockets, resolving DNS) that the main loop does not.
For existing open source software, search the OpenBSD ports tree for pledge calls in patches:
The ports tree at /usr/ports is the single best reference for pledge usage patterns on real software. Every patched daemon - nginx, sshd, httpd, ldapd - has been through this process.
# search ports patches for pledge usage examples
grep -r 'pledge' /usr/ports/*/patches/ 2>/dev/null | head -40
# look at how httpd does it specifically
cat /usr/ports/www/apache-httpd-openbsd/patches/patch-server_mpm_prefork_prefork_c
unveil Pitfalls: Symlinks, Bind Mounts, and /dev
Three areas cause most of the production problems we see with unveil.
First: symlinks. unveil resolves symlinks at the time of the call. If you unveil("/etc/ssl/cert.pem", "r") and that path is a symlink to /etc/ssl/certs/ca-bundle.crt, you need to unveil the real path too, or the open will fail. On OpenBSD 7.4 and earlier this was a source of subtle bugs. Check with:
realpath /etc/ssl/cert.pem
Then unveil both the symlink and the resolved path if you need both.
Second: /dev access. Processes that need /dev/null, /dev/random, or /dev/urandom must unveil /dev explicitly. The "stdio" promise covers read/write on already-open file descriptors but does not grant access to open new ones by path. On our test server, failing to unveil /dev broke several daemons that opened /dev/null to redirect stdin/stdout during daemonization.
Third: shared libraries. If your binary uses dlopen(3) at runtime to load plugins from a variable path, you cannot know those paths at unveil time. The workaround is to unveil the plugin directory with "rx" before locking. If the plugin directory is user-controlled, reconsider the architecture - this is a genuine security boundary problem, not a pledge/unveil limitation.
/* handle /dev and symlinks correctly */
if (unveil("/dev/null", "rw") == -1) err(1, "unveil /dev/null");
if (unveil("/dev/urandom", "r") == -1) err(1, "unveil /dev/urandom");
/* resolve symlink before unveiling */
char resolved[PATH_MAX];
if (realpath("/etc/ssl/cert.pem", resolved) == NULL)
err(1, "realpath");
if (unveil(resolved, "r") == -1) err(1, "unveil cert");
if (unveil("/etc/ssl/cert.pem", "r") == -1) err(1, "unveil cert symlink");
pledge Promises You Will Get Wrong the First Time
Several promise groups have non-obvious scope. "proc" allows fork(2) and most process control calls but NOT exec - you need "exec" separately for that. "id" covers setuid, setgid, and related calls - daemons that drop privileges after startup need "id" only during the privilege drop phase, then can pledge again without it.
The "prot_exec" promise deserves special attention. It allows mmap and mprotect calls that create executable memory regions. Any JIT-compiled language runtime (Lua with JIT, JavaScript engines, some Python bytecode optimizers) needs this. If you see SIGABRT from a program using a scripting language, check whether the interpreter is trying to mmap executable pages.
The "tty" promise is required for any process that calls tcsetattr(3), ioctl on a tty device, or openpty(3). SSH clients and terminal multiplexers need this. Without it, setting terminal modes kills the process.
The "recvfd" and "sendfd" promises control passing file descriptors over Unix sockets. Privilege-separated daemons like privsep-ssh and OpenBSD's own smtpd use this pattern - a privileged parent holds resources and passes fds to an unprivileged child over a socket pair. Both sides need the appropriate recvfd/sendfd promise.
/* privilege drop pattern with id promise */
if (pledge("stdio rpath inet dns id", NULL) == -1)
err(1, "initial pledge");
/* do startup work, open sockets, read config */
if (setuid(daemon_uid) == -1) err(1, "setuid");
if (setgid(daemon_gid) == -1) err(1, "setgid");
/* drop id promise now that privilege drop is complete */
if (pledge("stdio rpath inet dns", NULL) == -1)
err(1, "post-drop pledge");
Scripting Languages and Interpreted Runtimes
You cannot call pledge(2) or unveil(2) from a shell script directly - they are C library functions wrapping syscalls. But you can wrap an interpreter invocation with a pledge-aware launcher binary, or use rc.d service restrictions where available.
For Perl, Python, and Ruby scripts, the OpenBSD ports tree patches the interpreter itself to call pledge early. For custom scripts you control, the practical approach is a small C wrapper:
This launcher opens any resources the script needs before exec, passes them as file descriptors, then pledges away everything the script cannot use. If you are running automation pipelines or CI jobs, this pattern integrates cleanly with job runners. Platforms like taskbotshub.ai that orchestrate build pipelines can invoke these pledge wrappers as the actual command, so each job step runs inside a restricted process without changes to the script itself.
For Go programs, OpenBSD's Go port exposes pledge and unveil as syscall.Pledge() and syscall.Unveil() since Go 1.17. Call them the same way you would from C - unveil first, lock, then pledge.
/* pledge-wrapper.c: exec a script with restricted privileges */
#include
#include
int main(int argc, char *argv[]) {
if (argc < 2) errx(1, "usage: pledge-wrapper