Blog
What a child process inherits on Unix
Every command a shell or an agent runs starts with a fork that copies the parent process almost exactly, then usually an exec that swaps in a new program while keeping most of what fork copied. Knowing exactly what survives that handoff is what tells you where a secret is, and is not, safe to put.
A child process on Unix is created by fork, which duplicates the parent, then usually by exec, which replaces the program image but keeps nearly everything fork copied: the environment block, open file descriptors (unless marked O_CLOEXEC), the working directory, umask, user and group IDs, resource limits, and the disposition of any signal not actively being caught. exec resets caught-signal handlers and closes close-on-exec descriptors; little else changes. On Linux, any process running as your user ID can read another one's environment from /proc/<pid>/environ. That is why handing a command a secret through an environment variable protects it from strangers, not from anything else running as you.
Fork, then exec: the two-step model
Starting a new program on Unix is always two steps, even when a shell hides them behind one command you type. fork makes a near-exact copy of the calling process: same memory contents, same open files, same everything, now running as a second process with its own process ID. exec (the underlying system call is execve) then replaces that copy's program image with a different binary, loaded into the same process.
Almost nothing resets at either step, which is the part worth sitting with. The Linux fork(2) manual page describes the child as an exact duplicate of the parent process, aside from a short, specific list of exceptions: its own PID, a zeroed resource-usage and CPU-time counter, an empty pending-signal queue, and a narrower tail covering things like memory locks, semaphore adjustments and pending timers, which the child does not inherit. Everything a child could use to misbehave, the environment, the files it has open, the directory it is in, who it is allowed to act as, travels forward by default. This is also the model behind every tool call a coding agent makes: each command it runs is a fork of the shell it is sitting in, then an exec of whatever binary that command names.
The environment block, and why export matters
Every process keeps its environment as a flat list of name=value strings, pointed to by the C library's environ variable. As environ(7) puts it, when a child process is created via fork, it inherits a copy of its parent's environment, and exec carries that copy into the new program unless the caller deliberately builds a different list to pass in.
That is the mechanism behind export in a shell. A plain shell variable lives only in the shell's own memory and is never visible to anything the shell runs. export FOO=bar marks it for inclusion in environ instead, so every command the shell forks from that point on, and every process those commands fork in turn, inherits a copy with no further action from you and no point where it gets dropped.
Open file descriptors, and the O_CLOEXEC escape hatch
File descriptors follow the same rule. fork copies the whole descriptor table, so the child's descriptor 3 refers to the same open file as the parent's descriptor 3, not a new copy of it, and writes from either side land in the same place. exec, according to its own manual page, leaves that table alone too, with one deliberate exception: a descriptor opened with O_CLOEXEC, or marked afterward with fcntl(fd, F_SETFD, FD_CLOEXEC), is closed automatically the moment exec runs, rather than handed to the new program.
That flag is the only standard way to stop a file handle from reaching whatever a process execs next. A file opened without it, a log file, a socket, a pipe carrying something sensitive, stays available to every program that process goes on to run, by default, not by mistake.
Working directory, umask, user and group IDs, and resource limits
A handful of other attributes follow the identical rule: fork copies them exactly, and execve(2)'s own manual page states that all process attributes are preserved across an exec except a short, named list covering signal handlers, memory mappings, and a few Linux-only flags. None of the following is on that exception list.
- Current working directory. A child starts wherever the parent was when it called
fork, and a program loaded byexecinherits that same directory rather than starting somewhere fixed. - umask. The file-creation mask that decides the permission bits on every new file a process creates travels through both
forkandexecunchanged. - Real, effective, and saved user and group IDs. The identity a process runs as carries forward exactly, aside from the specific, documented case of a set-user-ID or set-group-ID binary changing its effective ID during exec.
- Resource limits. CPU time, memory, and open-file-count ceilings set with
setrlimitapply to the child and to whatever it execs, until something in that chain explicitly lowers them.
Signal dispositions: caught, ignored, or default
Signals are the one place fork and exec genuinely disagree. fork copies the parent's signal dispositions exactly, including any handler function the parent installed. exec cannot do that, because a handler is a function pointer into code that no longer exists once the new program is loaded, so any signal the parent was actively catching is reset to its default action.
What exec does not reset is a signal already set to be ignored or already at its default: POSIX specifies that those two are left exactly as they were, and execve(2) states it directly. A shell that ignores SIGINT while running a background job passes that ignored state into whatever it execs next, silently, unless the new program installs its own handler.
Who else can read a process's environment on Linux
A process's environment is not private to it once it is running. Linux exposes it at /proc/<pid>/environ, and the ptrace(2) manual page classifies reading that file as a read access-mode check: permitted whenever the reader's user ID matches the target's real, effective, and saved user ID, or the reader holds the CAP_SYS_PTRACE capability, the same as root.
The restriction people expect to be protecting them here is not the one in force. Yama, the Linux security module that restricts ptrace-based debugging on most desktop distributions by default, only limits the attach class of operations, live debugging attachment, not the read class that covers /proc/<pid>/environ. A second, ordinary process you start under your own account, with no special privilege and no debugger permission granted, can read the full environment of any other process running as you, including one that forked from your shell a minute earlier.
What macOS actually allows
macOS has no /proc filesystem, but ps -Eww <pid> reaches the same data through a kernel call, and Apple's own XNU kernel source shows the result is gated by two separate checks rather than one. The first is a plain ownership check: the kernel refuses to hand back another user's process arguments or environment at all unless the caller is root.
The second applies even to your own processes, and it targets the environment specifically rather than the command line: the kernel omits the environment block for any process carrying the code-signing restricted flag, set on most hardened, notarized apps, unless System Integrity Protection is off or the caller holds a private entitlement. In practice, ps -Eww (doubling the -w flag, documented in macOS's own ps(1) manual page, removes the output-width truncation that would otherwise cut off a long environment block) can show you the environment of your own ordinary dev tools, a shell, a self-compiled binary, but not another user's processes under any normal configuration, and not a hardened app's process even if you are root.
How this works in Forkbench
Forkbench's Vault exists because of exactly this gap: an environment variable is visible to the process that holds it and to anything else running as the same user, which makes it a weak place to put a credential an agent is going to use. A secret you bind to a destination never reaches the agent's process at all. The command gets a stand-in value, and a local proxy substitutes the real credential into the outgoing request on its way to the destination that key belongs to.
The stated limit is exact, not implied. A secret with no established destination cannot be substituted, so it is injected into the command's environment instead, with setenv() followed by execvp(). It never hits stdout, the argument list, or the transcript, but a program running inside that process can still read what it was handed, the same mechanism this post just walked through. Binding a key to a destination is what moves it off that path entirely; leaving it unbound puts it back exactly where every environment variable already lives.
Related: What --dangerously-skip-permissions actually skips, Stop a coding agent from reading your .env file, Why an AI agent's credentials need a lifecycle
Frequently asked
Does a child process inherit its parent's environment variables?
Yes.
forkgives the child a copy of the parent's environment, andexeccarries that copy into the new program unless the caller replaces it with a different list. In a shell, only variables marked withexportare included in that list; a plain shell variable stays local to the shell and is never passed to anything it runs.What does O_CLOEXEC do?
It marks a file descriptor to be closed automatically the moment a process calls
exec, instead of staying open in whatever program gets loaded next. The Unix default is for file descriptors to surviveexec, which is why a socket or file handle you did not mean to share can end up reachable from a child program unless it was opened with this flag.Can another process read my environment variables on Linux?
Any process running under your own user ID can, by reading
/proc/<pid>/environ, and the default Linux restriction on ptrace-based debugging (Yama) does not block it, because that restriction only covers live debugging attachment, not this kind of read. Root, or anything holding CAP_SYS_PTRACE, can read any process's environment regardless of user.Does ps show other users' environment variables on macOS?
No, not under a normal configuration. macOS's kernel refuses to return another user's process arguments or environment to
psunless the caller is root, and separately withholds the environment, though not the command-line arguments, of a hardened, notarized process even from root, unless System Integrity Protection has been turned off.Why is an environment variable a weak place to put an API key for an AI coding agent?
Because it survives
forkandexecby default, so it is visible to the agent's own process, to every tool call the agent runs, and to every process any of those spawn, and on Linux it is also readable by anything else running as you. A command that needs a key can get it without the value ever reaching the agent's process; see how Forkbench's Vault does this.
Keep reading
Sources
- Linux fork(2) manual page: what a child process inherits
- Linux execve(2) manual page: process attributes preserved across exec
- Linux environ(7) manual page: the user environment
- Linux ptrace(2) manual page: ptrace access mode checking and Yama
- Apple XNU source: sysctl_procargsx in kern_sysctl.c
- macOS ps(1) manual page: the -E, -e and -w options