Runtime

Handling File Permissions and umask in Python CLIs

Create private files with no readable window, keep executable bits when replacing files, respect the umask, and warn about secrets files others can read.

Updated

Most of the time a CLI can ignore file permissions: it writes a report, the operating system applies the user’s defaults, everyone is happy. Three situations break that. Secrets — tokens, cached credentials, private keys — must not be readable by other users on a shared machine, not even for the millisecond between creating a file and calling chmod. Replacing an existing file atomically, the right way to write files, quietly resets its permissions, so a hook script loses its executable bit after your tool updates it. And checking files you are given: SSH refuses to use a private key that others can read, and a CLI that loads credentials should at least warn in the same situation. This guide explains the umask, shows the patterns that handle all three cases, and tests them under a deliberately hostile umask. It belongs to the filesystem paths and atomic writes topic.

Prerequisites

How permissions get decided

Requested mode minus umask How the operating system computes a new file’s permissions from the mode a program requests and the process umask. Requested mode minus umask open() asks 0o666 (rw-rw-rw-) umask 022 remove group/other write File created 0o644 (rw-r--r--) & ~umask kernel mkstemp asks for 0o600, so its files are private under any umask.

When a program creates a file, it asks for a mode — Python’s open() asks for 0o666 (read and write for everyone), mkdir for 0o777. The kernel then removes the bits set in the process’s umask, a per-process mask inherited from the shell. The common umask 022 removes write permission for group and others, producing 0o644 files and 0o755 directories; a stricter 077 produces 0o600 and 0o700. The umask belongs to the user: respecting it for ordinary output files is correct, because it encodes how they want to share their files. Overriding it is correct only when the content demands it — secrets must be private regardless of a permissive umask.

The recipe

# src/mytool/perms.py
from __future__ import annotations

import os
import stat
import tempfile
from pathlib import Path

PRIVATE_FILE = 0o600
PRIVATE_DIR = 0o700


def write_private(path: Path, data: bytes) -> None:
    """Create or replace PATH so that it is never readable by others, not even briefly."""
    path.parent.mkdir(mode=PRIVATE_DIR, parents=True, exist_ok=True)
    fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")   # mkstemp uses 0o600
    try:
        with os.fdopen(fd, "wb") as f:
            f.write(data)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp, path)
    except BaseException:
        os.unlink(tmp)
        raise


def write_preserving_mode(path: Path, data: bytes) -> None:
    """Atomically replace PATH, keeping its existing permission bits (e.g. 0o755 scripts)."""
    try:
        mode = stat.S_IMODE(path.stat().st_mode)
    except FileNotFoundError:
        mode = 0o666 & ~current_umask()
    fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
    try:
        with os.fdopen(fd, "wb") as f:
            f.write(data)
        os.chmod(tmp, mode)
        os.replace(tmp, path)
    except BaseException:
        os.unlink(tmp)
        raise


def current_umask() -> int:
    """Read the process umask (there is no getter, so set and restore it)."""
    mask = os.umask(0o077)
    os.umask(mask)
    return mask


def insecure_permissions(path: Path) -> str | None:
    """Explain why PATH is too open for a secrets file, or return None if it is fine."""
    if os.name == "nt":
        return None                                    # POSIX mode bits do not describe Windows ACLs
    st = path.stat()
    if st.st_uid != os.getuid():
        return f"{path} is owned by another user"
    if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
        return (f"{path} is accessible by other users (mode {stat.S_IMODE(st.st_mode):04o}); "
                f"run: chmod 600 {path}")
    return None

Private files without a race

The naive approach — path.write_text(token) then path.chmod(0o600) — leaves a window in which the file exists with umask-derived permissions, typically 0o644, readable by everyone. On a shared host another user can open it in that window, and an open file descriptor stays valid after the chmod. The correct approach is to create the file with the right mode from the start. tempfile.mkstemp always creates its file with mode 0o600 and O_EXCL, so write_private writes the secret into a file that was never readable by others and then atomically renames it into place. The lower-level equivalent is os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600); note that the umask still applies to that mode, but it can only remove bits, so 0o600 stays private.

The directory gets 0o700. Mind two details of Path.mkdir: with parents=True, only the last directory gets the requested mode (intermediate ones use defaults), and with exist_ok=True an existing directory keeps whatever mode it has. For a credentials directory you create, that is fine; for one that might already exist with loose permissions, check and fix it explicitly. Where to put such files is covered in storing app data with platformdirs.

Replacing a file without losing its mode

An atomic replace writes a new file and renames it over the old one — so the result has the new file’s permissions, 0o600 from mkstemp. For secrets that is what you want; for a user’s 0o755 script, 0o644 config or group-shared file, it silently changes their setup. write_preserving_mode reads the existing mode first and applies it to the temporary file before the rename. For files that do not exist yet, it computes what a normal open() would have produced: 0o666 minus the umask.

Ownership and extended attributes are not preserved by this pattern. For a CLI editing files in the user’s own directories that does not matter; tools that edit files owned by other users (running under sudo) need os.chown with the original owner as well.

Checking and fixing modes Terminal session showing a private token file, a script that kept its executable bit after an update, and a warning about a credentials file other users can read. Checking and fixing modes bash $ ls -l ~/.local/state/mytool/token.json ~/repo/.git/hooks/pre-push -rw------- token.json -rwxr-xr-x pre-push # still executable after mytool hooks update $ mytool deploy --credentials ./creds warning: ./creds is accessible by other users (mode 0644); run: chmod 600 ./creds Every warning names the command that fixes it.

Reading the umask

Python has no function that simply returns the umask: os.umask(new) sets a new mask and returns the old one. current_umask sets a strict temporary value and immediately restores the original. The brief change is harmless in a single-threaded CLI; in a multi-threaded program another thread could create a file during that instant, so read the mask once at startup. On Linux, /proc/self/status also reports it on a Umask: line, without changing anything.

Warning about loose permissions

insecure_permissions performs the check SSH performs on private keys: the file must be owned by the current user and must not grant any access to group or others. When it fails, the message includes the exact command that fixes it. Whether to refuse or warn is a policy decision — refusing is safer for long-lived tokens; warning is friendlier for a config file that merely might contain a secret. See reading secrets from env and files for where this check fits when loading credentials.

UX considerations

  • Respect the umask for ordinary output. Reports, exports and caches should be created with normal open() so users’ sharing preferences apply.
  • Override it only for secrets, and say so in the documentation: “the token cache is created with mode 600”.
  • Print fixes, not just problems. chmod 600 ~/.config/mytool/credentials is actionable; “insecure permissions” is not.
  • Never loosen permissions a user has tightened. Preserving mode on replace means a user who chmod 600-ed their config keeps it that way.
  • Avoid chmod 777 as a fix for anything. If your tool hits PermissionError, report the path and the operation; suggesting world-writable permissions creates security holes.
Which mode for which file? How a command line tool should choose permissions for ordinary output, secrets, replaced user files and checked input files. Which mode for which file? File Mode How Report or export user’s umask plain open() Token or key 0o600 always mkstemp + os.replace Replaced user file keep existing chmod temp before rename Credentials given to us check warn or refuse if group/other Override the umask only when the content demands it.

Testing the behaviour

The interesting tests set a deliberately permissive umask of 000 — the worst case — and check that private files still come out as 0o600, and that replacing a file keeps its executable bit:

# tests/test_perms.py
import os
import stat
import sys

import pytest

from mytool.perms import current_umask, insecure_permissions, write_preserving_mode, write_private

posix_only = pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")


def mode(path) -> int:
    return stat.S_IMODE(path.stat().st_mode)


@posix_only
def test_private_file_ignores_a_permissive_umask(tmp_path):
    old = os.umask(0o000)                              # worst case: everything world-writable
    try:
        target = tmp_path / "state" / "token.json"
        write_private(target, b"{}")
    finally:
        os.umask(old)
    assert mode(target) == 0o600
    assert mode(target.parent) == 0o700


@posix_only
def test_replacing_keeps_executable_bit(tmp_path):
    script = tmp_path / "hook.sh"
    script.write_text("#!/bin/sh\n")
    script.chmod(0o755)
    write_preserving_mode(script, b"#!/bin/sh\necho hi\n")
    assert mode(script) == 0o755 and script.read_text().endswith("echo hi\n")


@posix_only
def test_new_file_follows_umask(tmp_path):
    old = os.umask(0o027)
    try:
        write_preserving_mode(tmp_path / "report.txt", b"x")
    finally:
        os.umask(old)
    assert mode(tmp_path / "report.txt") == 0o640


def test_current_umask_does_not_change_it():
    before = current_umask()
    assert current_umask() == before


@posix_only
def test_insecure_permissions_are_explained(tmp_path):
    secret = tmp_path / "credentials"
    secret.write_text("token")
    secret.chmod(0o644)
    assert "chmod 600" in insecure_permissions(secret)
    secret.chmod(0o600)
    assert insecure_permissions(secret) is None

Each test restores the original umask in a finally block, because the umask is process-wide and would otherwise leak into later tests. The POSIX-specific tests are skipped on Windows, where mode bits do not describe access control.

Windows

Windows controls access with ACLs, not mode bits. os.chmod there only toggles the read-only attribute, os.umask exists but has little effect, and stat reports synthetic modes. Files in a user’s profile directory (%APPDATA%, %LOCALAPPDATA%) are private to that user by default through inherited ACLs, which is why storing secrets under platformdirs’ user directories is the practical cross-platform answer. For stronger guarantees, use the operating system’s credential store through storing tokens with keyring.

Conclusion

Permissions come from the requested mode minus the umask. Leave that alone for ordinary files; create secrets with a private mode from the start via mkstemp or os.open(..., 0o600) instead of chmod afterwards; put them in 0o700 directories; preserve the existing mode when atomically replacing user files; read the umask once at startup; warn or refuse when a credentials file is accessible to others, printing the chmod that fixes it; and test under a permissive umask, restoring it afterwards.

Frequently asked questions

Should my CLI call os.umask(0o077) at startup?

Only if every file it creates is sensitive. Setting a strict umask globally also makes ordinary exports private, which surprises users who share them. Prefer explicit modes for the sensitive files.

Does Path.write_text accept a mode?

No. Path.touch(mode=0o600) and Path.mkdir(mode=0o700) do, but touch followed by write_text still works on a file that was created private, so that pair is acceptable; the temporary-file pattern above is simpler and atomic as well.

Why does my file come out 0o664 instead of 0o644?

Your umask is 002, common on systems that give each user a private group. That is the user’s choice and correct to respect.

What about files created by subprocesses?

Children inherit your umask. If your CLI tightens it temporarily around a call (git clone of a repository containing secrets, for example), restore it afterwards.

Can I preserve permissions when copying files?

shutil.copy2 copies mode bits and timestamps; shutil.copyfile copies only content. Choose deliberately — copying a 0o600 file with copy2 into a shared directory keeps it private, which is usually right.