Applications that parse network input need a containment boundary that remains useful after the first parser bug. OpenBSD provides two kernel mechanisms that address different parts of that problem. unveil(2) limits which paths a process can see, while pledge(2) limits the system-call families it can use. Treating either call as a single hardening switch leaves gaps that are easier to see when the two interfaces are designed together.

The order of setup matters. A program normally opens or records the files it needs, calls unveil() for those paths and permissions, then drops privileges and calls pledge() before entering its main request loop. OpenBSD documents that the first unveil() call hides the rest of the filesystem from filesystem-related calls except for the named paths and permissions. A later call can reduce that view, but it cannot use the interface to add a path that was already hidden.

pledge() has a similar one-way quality. The process declares promises that describe permitted system-call groups. With an exec promise, a second argument can constrain promises for the next executable. The manual also notes that calling with a broader set later does not restore access. This makes configuration mistakes fail closed in useful ways, but it also means developers need to map the complete runtime path before the final call.

Separate filesystem visibility from operations

An unveil() rule answers a narrow question: may this process name a path for a particular class of operation? The manual defines permissions for reading, writing, creating, and executing. Granting read access does not grant write or create access. An executable path may need the execute permission even when the program reads a file from the same directory.

pledge() answers a different question: which system-call families can the process invoke at all? A process with filesystem visibility can still be unable to spawn another program, change its process group, or access a network socket when the promise list excludes those operations. Conversely, a broad promise list does not make a hidden path visible.

This division is why a service should not start with permissive promises and trim them only after testing. Start from a complete inventory of runtime behavior. Record the configuration files, certificates, temporary directories, Unix sockets, device nodes, and output paths the service opens. Then trace which stages need process creation, signals, networking, disk writes, and time changes. The final list should describe the steady-state request handler, not the administrator who installed the package.

/* Illustrative shape only. The exact promises depend on the program. */
unveil("/etc/example", "r") != -1 ||
    err(1, "unveil configuration");
unveil("/var/lib/example", "rwc") != -1 ||
    err(1, "unveil state directory");
unveil(NULL, NULL) != -1 ||
    err(1, "lock unveil");

pledge("stdio rpath wpath cpath inet", NULL) != -1 ||
    err(1, "pledge");

That example is not a template for an arbitrary daemon. A program that invokes a helper, reloads by executing itself, or writes rotated logs may need a different design. A program that imports a library which lazily opens timezone data can also fail after the view is locked. The useful output of testing is not a larger promise string. It is evidence that each permission corresponds to a known operation and that unexpected paths fail with a logged error.

Test failures as boundary evidence

A basic test suite can verify the positive path, but it should also prove that the boundary rejects behavior the service should never need. Attempt an open outside the unveiled set. Attempt execve() after a promise set without exec. Attempt a network connection from a component that only formats local files. Run the tests as the production user with the same working directory and privilege state. A test that passes only because it runs as root or with an unrestricted parent process does not validate the deployment.

Logging needs care. A denied operation can indicate an attempted attack, but it can also expose a missing operational dependency. Send those events to the normal service log with the process identity and operation. Do not silently erase them by expanding promises during a retry loop. Each expansion should have a reason and an owner.

Treat the final call as an architectural boundary

The strongest design places the final calls after startup work and before untrusted input reaches the parser. Configuration reloads then need a helper or a controlled restart rather than a temporary return to a broad execution mode. Tests should confirm that the process cannot undo the locked filesystem view or broaden its promises.

pledge() and unveil() are not a substitute for fixing parser defects, validating input, or running the service as an unprivileged user. They reduce the set of operations available after an error. The review question is whether that reduced set matches the service's job. If the answer is unclear, the sandbox definition is incomplete and should be treated as a deployment blocker.

References