← posts / infrastructure

A quoted ~ in your PATH runs whatever sits in ./~/: a one-line check for dotfiles, servers and CI images

export PATH="$PATH:~/.local/bin" never expands the tilde. Interactive bash hides it, but sh, zsh, env and Python search a folder literally named ~ under the current directory. The check, the fix, and where to grep.

if.codesOct 13, 2026 · 4 min read#linux#shell#security#devopsAI-assisted

This line is in a lot of dotfiles, Dockerfiles and CI configs:

export PATH="$PATH:~/.local/bin"

It looks right, and in your own terminal it usually works. But the ~ sits inside double quotes, so the shell never turns it into your home directory. PATH ends up holding the literal string ~/.local/bin, which is a relative path: a directory called ~ inside whatever directory a program happens to be running in. Anyone who can create ./~/.local/bin/ there (a cloned repo, an unpacked archive, a shared upload folder, /tmp) can put a binary in front of the one you meant to run.

Dominik Czarnota (disconnect3d) wrote this up on 2 October; below is a reproduction on two systems, the check, and the places worth grepping.

Why the tilde stays a tilde

Bash only performs tilde expansion on unquoted words. In an assignment it also expands a ~ right after the = or after a :, which is why the unquoted form works:

A literal tilde in a path creates a hidden, deceptive route for the system.
A literal tilde in a path creates a hidden, deceptive route for the system.
export PATH=$PATH:~/.local/bin      # expands: /home/you/.local/bin
export PATH="$PATH:~/.local/bin"    # stays literal: ~/.local/bin
export PATH="$PATH:$HOME/.local/bin" # expands, and is safe to quote

The unquoted version is fragile (a space in a path breaks the assignment), so the fix is the third line: use $HOME, which expands inside double quotes.

Why you never noticed: interactive bash covers for it

We planted an executable at ./~/.local/bin/whoru in a temp directory, set PATH with the quoted tilde, and asked different programs to run whoru. On Debian bookworm (bash 5.2.15) in Docker:

D=$(mktemp -d)
mkdir -p "$D/~/.local/bin"
printf '#!/bin/sh\necho PLANTED\n' > "$D/~/.local/bin/whoru"
chmod +x "$D/~/.local/bin/whoru"
cd "$D"
export PATH="/usr/local/bin:/usr/bin:/bin:~/.local/bin"

bash -c whoru
bash --posix -c whoru
sh -c whoru
zsh -c whoru
env whoru
python3 -c 'import subprocess; subprocess.run(["whoru"])'
bash -c whoru          bash: line 1: whoru: command not found
bash --posix -c whoru  PLANTED
sh -c whoru (dash)     PLANTED
zsh -c whoru           PLANTED
env whoru              PLANTED
python3 subprocess     PLANTED

Plain bash looks the command up in $HOME/.local/bin, because it expands a leading ~ in PATH entries when it searches. So the commands you type keep working and the bug stays hidden. Everything else takes the entry literally: /bin/sh on Debian and Ubuntu (dash), which is what make, system() and many scripts use; zsh; bash in POSIX mode; and anything that calls execvp directly, such as env, Python's subprocess and most language runtimes. On macOS with the stock bash 3.2 we saw the same split: bash and /bin/sh found nothing, while zsh, env and Python ran the planted file. Python's shutil.which("whoru") even returned ~/.local/bin/whoru.

The practical result: a tool you start from your shell can run a different binary than the one you get when you type the same name yourself.

The one-line check

From the original write-up:

Searching for hidden relative paths in environment configurations.
Searching for hidden relative paths in environment configurations.
echo "$PATH" | tr ':' '\n' | grep '~'

No output means no literal tilde. While you are there, also look for empty entries and ., which have the same effect (the current directory is searched):

echo "$PATH" | tr ':' '\n' | grep -n -v '^/'

That prints every entry that does not start with /: empty entries, ., a literal ~ and any other relative directory. Each line it shows is searched relative to wherever a program runs.

Need the backend underneath done properly?I build and harden Go and Python backends. The estimate is free.

Where to grep

Your shell's PATH is only one copy. Check the files that build other people's and other machines' environments:

grep -rn 'PATH.*~' \
  ~/.bashrc ~/.bash_profile ~/.profile ~/.zshrc ~/.zprofile ~/.config/fish 2>/dev/null

# in a repo: Dockerfiles, CI configs, scripts, systemd units
git grep -n 'PATH.*~' -- \
  '*Dockerfile*' '*.yml' '*.yaml' '*.sh' '*.service' '*.env'

This finds unquoted tildes too, which are fine in shell files; read each hit and fix the quoted ones and everything outside a shell.

  • Dockerfiles. ENV PATH="$PATH:~/.local/bin" is common after a pip install --user. ENV does not do tilde expansion at all, quoted or not (we built one to confirm), so every container from that image carries a literal ~/.local/bin. Use the absolute path: ENV PATH="/root/.local/bin:${PATH}" or /home/app/.local/bin for a non-root user.
  • CI env blocks. YAML env: values are plain strings handed to the runner; a ~ there is not expanded by anything. Write the full path, or append to the runner's path file with $HOME in a shell step.
  • systemd units. Environment=PATH=... takes the value literally. Use absolute paths.
  • Shared servers and jump hosts. These have the most writable directories that other users cd into, so a planted ./~/ folder has the best chance of being hit there. Check the system-wide files too: /etc/profile, /etc/profile.d/*, /etc/environment.

Fixing it

  1. Replace ~ with $HOME inside quotes: export PATH="$HOME/.local/bin:$PATH".
  2. In files that are not shell (Dockerfile ENV, YAML, systemd), write the absolute path.
  3. Open a new shell and run the check again. Then check from the tools that actually matter: sh -c 'echo $PATH' and env | grep ^PATH= inside the container or CI job.
  4. If the bad entry has been there a while on a shared machine, look for planted directories: find / -xdev -type d -name '~' 2>/dev/null.

Takes about five minutes per repo, and it is one of the few security fixes that is a find-and-replace.

Replacing relative tildes with absolute paths secures the environment.
Replacing relative tildes with absolute paths secures the environment.

Sources

if.codesI build RAG, AI integrations and agent pipelines on Go and Python backends — and write about it here.
// keep reading

More posts on AI and backends.

// free quote

Read something you need? I’ll quote it for free.

RAG, AI integrations, agents or the backend underneath — tell me what you have and what should change. I read every request myself.

Free · no commitment

Tell me what you have. I’ll tell you what it takes.

1Describe the projectA few sentences is enough — about two minutes.
2I review itI read it myself and may ask a follow-up question.
3You get a free quoteScope, approach and estimate — yours to keep, no strings.
What kind of project is it?
Free and without obligation. Your details are used only to reply — see the privacy policy.