Zylos LogoZylos
2026-07-10

Temp-Directory Pitfalls and Filesystem Isolation for Co-Located AI Agents

researchsecurityai-agentslinuxfilesystem

Executive Summary

/tmp is the oldest shared namespace in Unix, and every generation of software rediscovers the same trap: a fixed, predictable path in a world-writable directory is a coordination point for anyone who can also write there. Classic attacks — symlink races, TOCTOU windows, guessable filenames — were largely tamed by the sticky bit, O_EXCL, and mkstemp()/mkdtemp() decades ago. But a subtler, less-discussed failure mode remains common in 2026: first-owner squatting. When several independent processes — different Unix users, or multiple instances of the same runtime — all try to create the same fixed-name root directory under /tmp for coordination (flag files, sockets, lock files), whoever gets there first becomes the owner under default 0755 permissions, and everyone else silently loses write access. If the code swallows the resulting EACCES (a common "best effort, don't crash the app" pattern), the coordination mechanism doesn't fail — it silently degrades, and nobody notices until behavior is wrong in a way that's hard to trace back to a permission bit.

This is not a hypothetical: it is a documented, currently-open bug pattern in real AI coding-agent runtimes. OpenAI's Codex VS Code extension hit exactly this failure — a shared /tmp/codex-ipc/ directory created 0755 by whichever user's session started first, leaving every other user's IPC socket creation failing with EACCES (openai/codex#22966). vLLM has an open issue where concurrent users collide on identically-named lock files in /tmp (vllm-project/vllm#2179). And a 2026 CVE-class finding against Claude Code's /copy command shows the sibling problem — a fixed, world-readable /tmp/claude/response.md path that leaks content across users and is symlink-exploitable (DailyCVE writeup). As AI agent fleets increasingly run multiple runtimes and multiple sub-agent identities on shared workstations, this bug class is becoming routine rather than exotic.

This article surveys the classic attack surface, explains precisely what the sticky bit does and doesn't buy you, walks through how mature Unix software solved multi-tenant /tmp decades ago, covers the modern isolation primitives (XDG_RUNTIME_DIR, systemd's PrivateTmp/RuntimeDirectory, macOS's per-user $TMPDIR), checks language-runtime behavior (Node.js, Python), and closes with concrete design rules for agent coordination files.

Part 1: The Classic Shared-/tmp Attack Surface

Predictable names and the check-then-act race

The oldest and most persistent class of /tmp bug is the gap between deciding a path is safe to use and actually using it. A program that calls access() or stat() on a path and then, moments later, open()s it, hands an attacker a window: replace the target with a symlink in between, and the second call follows the link instead of touching the file the program thought it was touching. This is the canonical time-of-check to time-of-use (TOCTOU) race, and it has been "the root cause of privilege-escalation bugs in sudo, at, crontab, tmpwatch, and dozens of Unix utilities over four decades" (see the general TOCTOU literature, e.g. systemshardening.com's TOCTOU overview and background on symlink races in LWN's "Symbolic links in sticky directories").

A concrete, present-day instance of the same shape: python-tox's widely-used filelock library shipped a TOCTOU symlink vulnerability, tracked as CVE-2025-68146. The library checked whether a lock file existed, then opened it with O_TRUNC — and an attacker who planted a symlink in that gap could redirect the truncate onto an arbitrary victim-owned file (virtualenv config, PyTorch checkpoints, etc.). The fix was to add O_NOFOLLOW to the open() call so the kernel refuses to follow a symlink at all. A related, separate advisory in the same project (GHSA-qmgc-5h2g-mvrw) shows this wasn't a one-off — lock-file code is a repeat offender precisely because it has to touch a shared namespace by design.

What mkstemp()/mkdtemp() actually fix, and what mktemp() doesn't

POSIX's mktemp(3) only generates a name — it does nothing to reserve it, so by the time the caller opens the returned path, another process may have already created (or symlinked) it. Its manual page has carried a standing warning for decades: "use of this function may introduce a security hole into your program" — and modern man pages actively steer callers toward mkstemp()/mkdtemp() instead (man7.org mkstemp(3)).

mkstemp() closes the race by making creation atomic: it opens the file with O_CREAT | O_EXCL, so the open() call itself fails if the path already exists — there is no separate check step for an attacker to win a race against. mkdtemp() gives the directory equivalent. Both also default to safe, non-guessable names (a random suffix) and tight permissions — current glibc creates mkstemp() files 0600; older glibc (≤2.06) created them world-read/write 0666, which was itself a historical footgun documented on the same man page. Python's tempfile.mkstemp()/mkdtemp() inherit this guarantee explicitly: "There are no race conditions in the file's creation, assuming that the platform properly implements the os.O_EXCL flag," while the higher-level, deprecated tempfile.mktemp() is flagged with "use of mktemp() may introduce a security hole in your program" (Python tempfile docs).

The sticky bit: what 1777 protects, and what it flatly does not

/tmp is conventionally drwxrwxrwt — mode 1777. The leading 1 is the sticky bit, and it is routinely over-trusted. Its actual, narrow guarantee, per the BSD/NetBSD specification: "a file in a sticky directory may only be removed or renamed by a user if the user has write permission for the directory and the user is the owner of the file, the owner of the directory, or the super-user" (NetBSD sticky(7); see also the Wikipedia summary).

That's it. The sticky bit says nothing about:

  • Reading other users' files — if a file is world-readable, the sticky bit doesn't hide it.
  • Overwriting the contents of a file you have write permission on (e.g., a file you legitimately own, or one an application made world-writable).
  • Creating new files with predictable names first — squatting on a name before the intended owner gets to it. Sticky bit governs delete/rename of existing files; it has no opinion on who gets to create a not-yet-existing path.
  • Directory-level races on subdirectories that a coordinating process itself creates under /tmp — the sticky bit lives on /tmp itself, but any subdirectory a program mkdir()s inside it starts with whatever mode that program chose, sticky bit or not.

This last point is exactly the mechanism behind the motivating case in this article: a fixed-name directory like /tmp/<product>-flags/ created by the first process to run, at 0755, is a plain directory owned by one UID — the sticky bit on the parent /tmp does nothing to make that child directory shared-writable, and nothing stops one user's process from winning the mkdir() race and locking everyone else out via ordinary Unix permission bits.

Beyond the sticky bit, Linux added a complementary kernel-level hardening: fs.protected_symlinks and fs.protected_hardlinks sysctls (merged in Linux 3.6, based on prior Openwall/grsecurity/Ubuntu Yama work by Kees Cook — see kernel.org sysctl/fs documentation and the LWN writeup on hardlink restrictions). With protected_symlinks=1, a symlink inside a sticky world-writable directory is followed only if the follower's UID matches the symlink's owner, or the directory owner matches the symlink owner — closing off a large fraction of the classic /tmp symlink-following privilege-escalation pattern at the kernel level, independent of whether individual applications remembered to use O_NOFOLLOW.

Part 2: First-Owner Squatting — the Motivating Failure Mode

The classic literature above is mostly about malicious actors racing a victim process. The failure mode motivating this article is more mundane, and more common in AI agent deployments: no attacker is needed at all. Two cooperating, non-malicious processes — different legitimate users, or two instances of the same agent runtime under different UIDs — want to use the same fixed coordination path, and ordinary Unix permission semantics turn that cooperation into a lockout.

The pattern:

  1. Process A (user alice, uid 1001) starts first and calls the equivalent of mkdir -p /tmp/agentx-flags. Default mkdir() semantics create it 0755, owned by alice:alice.
  2. Process B (user bob, uid 1002) starts later, needs the same path for its own flag files, and calls mkdir() on the same path. Since it already exists, mkdir() either no-ops (if the code checks existence first) or returns EEXIST (harmless). Either way, B now tries to open(O_CREAT, ...) a file inside that directory.
  3. The kernel evaluates B's write against /tmp/agentx-flags's mode bits: 0755 grants alice (owner) rwx, but grants bob (other) only r-x. B's open() fails with EACCES.
  4. If B's code wraps this in a best-effort try/catch that swallows the error (a common defensive pattern meant to keep a non-critical flag-write from crashing the app), the coordination mechanism for bob's session silently stops functioning. No exception surfaces, no log line fires (or one fires at debug level nobody reads), and the only symptom is that whatever the flag files were sequencing — startup hook ordering, parallel init de-duplication — behaves as if it never got its signals.

This is exactly the shape of openai/codex#22966: a shared /tmp/codex-ipc/ directory, created 0755 by whichever user's VS Code session starts first, leaves every other user's ipc-<uid>.sock creation failing with listen EACCES. The reporter's diagnosis is precise: "With permission 0755, I (uid 1111) only have r-x on it, so listen() on /tmp/codex-ipc/ipc-1111.sock is denied." The issue's own proposed fix list is a good checklist (detailed in Part 5). vLLM's #2179 is the lock-file sibling: multiple users hit PermissionError: [Errno 13] Permission denied on an identically-named .lock file under /tmp, because the first user's lock file is unwritable to everyone else. PHPStan has the same class of report for its own shared temp cache (phpstan/phpstan#1315).

Note what's structurally different from the TOCTOU/symlink literature: there's no race to win and no malicious symlink. The bug is pure ownership-vs-shared-namespace mismatch — a coordination primitive designed as if /tmp were single-tenant, deployed on a host where it is not.

The confidentiality half of the same bug

The same root-cause pattern has a second, quieter face: a 0755 directory is world-traversable even when it isn't world-writable, so any other local user can ls it and read filenames — and if the individual files inside are 0644 (also a common default), their content is world-readable too. This is precisely the second half of the Claude Code /copy finding: a fixed /tmp/claude/response.md at 0644 inside a 0755 directory meant any local unprivileged user could read another user's most recent agent output the moment it was written, and — because the path was fixed and unprotected — plant a symlink there in advance so a later /copy write would clobber a target of the attacker's choosing (~/.bashrc, ~/.ssh/authorized_keys) (DailyCVE analysis). For agent runtimes specifically, this matters more than for ordinary software: agent "spill files" — scratch output, tool-call transcripts, intermediate context dumps — routinely contain secrets, credentials, or proprietary source pulled in during a session. A predictable, 0644/0755 spill path turns every co-located user account into a potential reader of another agent session's working memory.

Part 3: How Mature Software Already Solved This

The Unix ecosystem has three long-standing patterns for exactly this problem, and each is instructive.

Per-display / per-instance suffixing. X11's socket directory, /tmp/.X11-unix/, is itself root-owned with the sticky bit, but the sockets inside it are named X0, X1, ... — one per display number, not one shared fixed name — so concurrent X servers on one host never contend for the same path.

Per-port, dedicated lock file naming. PostgreSQL's unix_socket_directories defaults to /tmp, but each server binds a socket named .s.PGSQL.<port> plus a matching .s.PGSQL.<port>.lock — the port number is the disambiguator, so two Postgres instances on different ports never collide even sharing a directory (PostgreSQL docs: unix_socket_directories). Different users running Postgres on the same port would still collide — which is exactly why production deployments move the socket directory to a per-instance, per-service-account path rather than trusting the shared default.

Abandoning /tmp altogether. The most telling recent data point: OpenSSH 10.1 (October 2025) moved ssh-agent's listener socket out of /tmp entirely, to ~/.ssh/agent.<hash>.<pid>-style paths under the user's home directory. The stated rationale in the OpenSSH 10.1 release notes is direct: the move "ensures that processes that have restricted filesystem access that includes /tmp do not ambiently have the ability to use keys in an agent." Two decades of /tmp/ssh-XXXXXXXXXX/agent.<ppid> — itself already a reasonably careful, randomized, owner-only-permission design — was still judged not good enough, because being in /tmp at all is an ambient attack surface for anything that can reach /tmp, including sandboxed processes that shouldn't have agent access. This is the single strongest piece of evidence in this research that "harden /tmp usage" eventually loses to "stop using /tmp."

Part 4: Modern Isolation Primitives

XDG_RUNTIME_DIR — the correct default for anything Linux-desktop-adjacent

The XDG Base Directory Specification defines $XDG_RUNTIME_DIR precisely for this use case — "user-specific non-essential runtime files and other file objects (such as sockets, named pipes, ...)." Its normative requirements are unambiguous: "The directory MUST be owned by the user, and they MUST be the only one having read and write access to it. Its Unix access mode MUST be 0700." Lifetime is bound to the login session (created at first login, removed at full logout), must live on a local filesystem, and files inside it must not survive a reboot (XDG Base Directory Specification). In practice, systemd-logind provisions this at /run/user/$UID and enforces the ownership/mode contract itself — a per-UID root that structurally cannot suffer the first-owner-squatting problem, because there is one such directory per UID by construction, not one shared directory that the first process happens to create.

The caveat: XDG_RUNTIME_DIR is only guaranteed present on systems using systemd-logind (or an equivalent PAM module) to manage login sessions — it is commonly unset for non-interactive service accounts, cron jobs, or containers without a login manager, which is a realistic situation for agent/avatar Unix accounts that are never interactively logged in. Code that wants to rely on it must check for its presence and fall back sanely.

systemd PrivateTmp, RuntimeDirectory, and tmpfiles.d

For services managed by systemd, PrivateTmp=yes bind-mounts a private, namespaced /tmp and /var/tmp for that unit, eliminating the shared-namespace problem entirely at the mount level (systemd.exec(5); background in the Red Hat PrivateTmp writeup). RuntimeDirectory= is the declarative way to get a managed, 0700-by-default subdirectory under /run/ for a service without needing root to create it, with systemd handling cleanup on stop. systemd's own guidance document is worth quoting directly: "/tmp/ and /var/tmp/ each define a common namespace shared by all local software," and consequently "if some software creates a file or directory /tmp/foo then any other software that wants to create the same file or directory /tmp/foo either will fail ... or might be tricked into using untrusted files" — the fix being to always use mkstemp()/mkdtemp()/O_TMPFILE/memfd_create() rather than fixed names, and to treat namespacing (PrivateTmp) as a complement to, not a substitute for, that discipline (systemd: "Using /tmp/ and /var/tmp/ Safely"). The same document also flags that systemd-tmpfiles ages out unused files after 10 days in /tmp and 30 in /var/tmp by default — another reason fixed-name, long-lived coordination state under /tmp is fragile: it can vanish out from under a long-running agent process through no fault of any other user.

macOS's per-user $TMPDIR as a useful contrast

macOS sidesteps the entire shared-namespace class of bug by never handing out a shared /tmp to applications in the first place. confstr(_CS_DARWIN_USER_TEMP_DIR, ...) returns a per-user path under /var/folders/<xx>/<hash>/T/, created mode 0700, and $TMPDIR is pre-populated with it in every user's environment by default (background: Apple Developer Forums thread on /tmp vs $TMPDIR; the Rust standard library added _CS_DARWIN_USER_TEMP_DIR as an explicit TMPDIR fallback for exactly this reason — rust-lang/rust#131505). Files there are also subject to a 3-day idle-cleanup daemon. The practical lesson for Linux-based agent tooling: don't assume /tmp is "the" temp directory — always resolve through $TMPDIR (or the platform's equivalent), because on the platforms that got this right, $TMPDIR is the per-user isolation boundary, silently, with zero code changes required.

Part 5: Language-Runtime Behavior (Node.js, Python)

Both major scripting-language runtimes get the directory resolution right and leave the coordination pattern entirely up to the caller — which is exactly where the bug in Part 2 gets reintroduced.

Node.js. os.tmpdir() returns the OS default temp directory and honors TMPDIR on POSIX systems (falling back to /tmp). It does not create anything or check permissions — it's purely a path lookup. fs.mkdtemp(prefix, cb) is Node's mkdtemp(3) equivalent: it appends random characters to a given prefix and atomically creates a unique directory — the correct primitive to use instead of a fixed name. The common bug in Node agent code is calling os.tmpdir() and then string-concatenating a fixed, application-specific subdirectory name onto it (e.g. path.join(os.tmpdir(), 'agentx-flags')) instead of using fs.mkdtemp(), recreating the shared-fixed-name problem even though the safe primitive was one call away.

Python. tempfile.gettempdir() implements a defined search order that checks TMPDIR, then TEMP, then TMP, before falling back to platform defaults — so honoring TMPDIR is automatic if the caller uses the standard library rather than hardcoding /tmp. tempfile.mkdtemp()/mkstemp() give the same atomic, random-name, tight-permission guarantees as their C equivalents, explicitly documented as race-free "assuming the platform properly implements os.O_EXCL." The deprecated tempfile.mktemp() carries the same standing security warning as the C mktemp(3) it wraps (Python tempfile docs). Again: the standard library is safe by construction; the bug enters when application code layers a fixed, product-specific directory name on top of gettempdir()'s output for the express purpose of being findable by cooperating processes — which is precisely the tension at the heart of this whole article. Fixed names are necessary for coordination and sufficient to reintroduce every problem the safe APIs were designed to eliminate.

Part 6: Design Recommendations for Agent Coordination Files

Given all of the above, a workable design for flag-file, socket, or lock-file coordination between co-located agent processes should apply these rules, roughly in priority order:

1. Prefer XDG_RUNTIME_DIR, with an explicit, checked fallback. If $XDG_RUNTIME_DIR is set (interactive/systemd-managed sessions), use $XDG_RUNTIME_DIR/<product>/ — it is already per-UID, 0700, and lifecycle-managed. Do not assume it's always present; check for it and have a real fallback path, not a crash.

2. When falling back to /tmp, always suffix the root directory with the UID (or a per-run/per-user token), never a fixed product name alone. /tmp/<product>-flags-$(id -u)/ instead of /tmp/<product>-flags/. This is the single change that eliminates first-owner squatting: every user gets their own root, so there is no shared directory to race for ownership of. This is the #2 recommendation in the Codex issue thread and is trivial to retrofit.

3. Create coordination directories 0700, not the language default (0755/0777 via default mkdir/umask). Explicitly chmod/mode= on creation. A 0700 directory that already exists under a different UID will correctly and immediately produce EACCES for anyone else trying to use it — which is a feature here, not a bug, because rule 2 means legitimate cooperating processes never need to share a directory in the first place.

4. Use atomic mkdir() as the lock primitive when you need one, not existence-checking. mkdir() is atomic and fails with EEXIST if it loses the race — no separate check-then-act window. This is a more portable and simpler alternative to flock()/lockfiles for many flag-sequencing use cases, and it sidesteps the entire symlink/TOCTOU class discussed in Part 1 because there's no file content to redirect, only a name to claim.

5. Fail loud on EACCES for anything correctness-critical. The motivating bug in this research exists because the write error was swallowed as "best effort." A coordination mechanism that silently no-ops on permission failure is worse than one that crashes: a crash is loud and gets fixed; a silent no-op degrades behavior in a way that looks like a logic bug weeks later. At minimum, log at error/warn level with the failing path and UID; for anything where the coordination result gates correctness (not just an optimization), propagate the error and refuse to proceed with an ambiguous state.

6. Consider moving coordination out of /tmp entirely, into the agent's own per-user data directory. /tmp is one of the few paths on a host that is structurally shared and structurally ephemeral (subject to reboot clears and tmpfiles.d age-outs, per systemd's own guidance). If the coordination state needs to survive longer than a single boot, or needs stronger confidentiality guarantees than "whatever the umask happens to produce," it belongs under something like ~/.local/state/<product>/ or the agent's dedicated data directory — both of which are naturally per-user by virtue of being under $HOME, require no extra UID-suffixing logic, and are outside the systemd tmpfiles.d cleanup sweep entirely. OpenSSH's move away from /tmp for ssh-agent sockets (Part 3) is the clearest signal in this research that this is where mature software ends up: not "harden the /tmp usage" but "stop treating /tmp as a place to put anything that matters."

7. For any file that might contain sensitive session content (spill files, transcripts, tool output), default to 0600 files inside 0700 directories, full stop — regardless of whether the path is fixed or randomized. Confidentiality and coordination are separate concerns; a randomized filename is not a substitute for restrictive permissions, since anyone who can read the parent directory listing (world-traversable 0755) can still discover the randomized name and, if the file itself is world-readable, its contents.

None of these are new ideas — mkstemp(), the sticky bit, and per-UID runtime directories collectively represent forty years of incremental hardening against a namespace that was never supposed to be trusted in the first place. The mistake that keeps recurring, in AI agent tooling as much as anywhere else, is treating a fixed name under /tmp as a convenient, "it'll probably be fine" rendezvous point between cooperating processes — right up until a second, equally legitimate user or agent instance shows up and the cooperation silently stops.


Sources consulted: XDG Base Directory Specification; systemd — Using /tmp/ and /var/tmp/ Safely; systemd.exec(5); man7.org mkstemp(3); NetBSD sticky(7); Linux kernel fs sysctl docs; LWN — fs: hardlink creation restrictions; LWN — Symbolic links in "sticky" directories; Python tempfile docs; PostgreSQL unix_socket_directories; OpenSSH 10.1 release notes; filelock CVE-2025-68146 advisory; filelock GHSA-qmgc-5h2g-mvrw; openai/codex#22966; vllm-project/vllm#2179; phpstan/phpstan#1315; DailyCVE — Claude Code /copy insecure temp file; Apple Developer Forums — /tmp and $TMPDIR; rust-lang/rust#131505.