The first time os.chmod() really mattered to me wasn’t in a tutorial—it was in a late-night deploy where a maintenance script silently failed. The code was correct, the server was healthy, and the logs looked fine. The real issue was embarrassingly simple: the script didn’t have the execute bit set after it was copied into place. On Linux and macOS, “can I run this file?” is often a permissions question before it’s anything else.
os.chmod() is the Python tool for changing file and directory permission bits programmatically. Once you’re automating builds, generating artifacts, shipping CLI tools, writing installers, or managing shared folders, you’ll hit situations where permissions decide whether your system works—or fails in ways that are hard to notice.
I’ll walk you through how permission bits actually behave, how os.chmod(path, mode) maps to what you see in ls -l, how to safely modify permissions without accidentally wiping existing bits, and how to handle edge cases like directories, symlinks, and Windows. Along the way, I’ll show runnable patterns I use in production scripts and CI pipelines.
A working mental model: mode bits are a tiny access policy
On Unix-like systems (Linux, macOS, many containers), each filesystem entry has a set of mode bits that encode permissions and a few special behaviors. When you run chmod in a shell, you’re flipping those bits. When you call os.chmod(), you’re doing the same thing—just from Python.
Think of the mode as three permission “rows” (owner, group, others), and three permission “columns” (read, write, execute):
- Read (
r): can read the file contents (or list a directory) - Write (
w): can modify the file contents (or create/remove items in a directory) - Execute (
x): can execute the file (or traverse a directory)
If you’ve seen -rwxr-xr--, that’s exactly these bits rendered.
Two details trip people up:
1) Directories treat bits differently
rlets you list names in the directory.xlets you enter/traverse the directory and access items if you already know their names.wlets you create/remove/rename entries (usually paired withxto be useful).
2) os.chmod() sets the mode you pass
This is critical: os.chmod(path, mode) doesn’t “add read permission.” It replaces the permission bits with whatever you provide. If you want to add or remove a bit, you typically read the current mode first, then adjust it.
os.chmod() basics: signature, behavior, and what “mode” means
The core call is:
os.chmod(path, mode)
Where:
pathis a string/bytes/path-like object pointing to a file or directorymodeis an integer containing permission bits (often written in octal like0o644)
On Unix, Python’s stat module defines constants for these bits (and a few special flags):
- Owner permissions:
stat.SIRUSR,stat.SIWUSR,stat.SIXUSR(andstat.SIRWXU) - Group permissions:
stat.SIRGRP,stat.SIWGRP,stat.SIXGRP(andstat.SIRWXG) - Others permissions:
stat.SIROTH,stat.SIWOTH,stat.SIXOTH(andstat.SIRWXO) - Special bits:
stat.SISUID,stat.SISGID,stat.S_ISVTX
Python also provides related APIs you should know:
pathlib.Path(path).chmod(mode)(nice ergonomics)os.fchmod(fd, mode)(operate on an open file descriptor)os.chmod(..., follow_symlinks=False)on many Unix builds (don’t follow symlinks)os.stat()/os.lstat()to inspect mode bits
A very important platform note:
- On Windows,
os.chmod()exists, but permission semantics differ. Windows ACLs are not the same as Unix mode bits. In practice,os.chmod()on Windows often only affects the “read-only” attribute in a limited way. If you need real authorization control on Windows, you typically work with ACL tooling rather thanchmod-style bits.
Octal vs stat constants: how I choose in real code
You’ll see modes written like 0o644 or built from stat constants. Both are valid; the difference is readability and safety.
Quick reference: common octal modes
These are the ones I use most:
0o600→ private file (owner read/write)0o644→ typical data file (owner read/write, others read)0o700→ private directory (owner full)0o755→ typical directory (owner full, others traverse + read)0o750→ team directory (owner full, group traverse + read)
I like octal when the intent is a known “standard” permission set.
Building mode from constants
I prefer stat constants when I’m adjusting a specific bit or writing code that reads like a policy.
Here’s a complete, runnable example that creates a file and locks it down to owner-only read/write (0o600):
from future import annotations
import os
import stat
from pathlib import Path
def createprivatetoken_file(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.writetext("apitoken=replace-me\n", encoding="utf-8")
# Owner read + write only (equivalent to 0o600)
privatemode = stat.SIRUSR | stat.S_IWUSR
os.chmod(path, private_mode)
if name == "main":
token_path = Path(".secrets/runtime/token.env")
createprivatetokenfile(tokenpath)
print(f"Wrote {token_path} with restricted permissions")
When you run this on Linux/macOS, ls -l .secrets/runtime/token.env should show something like -rw-------.
A rule I follow: never hardcode 0o777
If you’re tempted to set 0o777 (world-writable) to “fix” an access problem, pause. In shared machines, containers with mounted volumes, or CI runners, that can turn into a security hole or a weird debugging session later.
If you need collaboration, prefer:
- group ownership (
chgrp) and0o770/0o775, or - per-user directories, or
- a controlled shared directory with the sticky bit (more on that later)
The most common trap: chmod overwrites, so preserve existing bits
A frequent mistake is treating os.chmod() like an “add permission” API:
# This is WRONG if you mean "add execute".
import os
import stat
os.chmod("scripts/backup.sh", stat.S_IXUSR)
That call sets the mode to “owner execute only” (and removes read/write bits). You probably meant “keep current permissions, add execute for owner.”
Here’s the pattern I recommend: read current mode, mask to permission bits, then modify.
from future import annotations
import os
import stat
from pathlib import Path
def addownerexecute(path: Path) -> None:
st = os.stat(path)
# st.stmode includes file type bits; isolate permissions with SIMODE
currentperms = stat.SIMODE(st.st_mode)
newperms = currentperms | stat.S_IXUSR
os.chmod(path, new_perms)
if name == "main":
script_path = Path("scripts/backup.sh")
scriptpath.parent.mkdir(parents=True, existok=True)
scriptpath.writetext("#!/usr/bin/env sh\necho ‘backup running‘\n", encoding="utf-8")
# Start with a sensible baseline: readable by owner, writable by owner
os.chmod(script_path, 0o600)
addownerexecute(script_path)
print(f"Made {script_path} executable for owner")
That small S_IMODE detail prevents accidental changes to the file type bits and keeps your intent focused.
Traditional vs modern workflow
Here’s how I think about this in 2026-era dev workflows:
Traditional approach
—
Run chmod +x manually
Debug on server
Hope nobody reads it
chmod 0o600 immediately If your team uses containers, devcontainers, or ephemeral CI runners, “manual chmod” doesn’t scale. Automated permission enforcement does.
Directories are special: execute means “can traverse”
For directories, the execute bit is the difference between “I can see the names” and “I can actually access things inside.” This is why you sometimes see confusing scenarios like:
- You can
lsa directory, but cannotcata file inside. - Or you cannot
cdinto a directory even though you can read it.
A safe recursive permission setter
A real-world need: after generating a build output tree, you want:
- directories:
0o755 - files:
0o644
Here’s a complete script that does that, while keeping symlinks untouched by default:
from future import annotations
import os
from pathlib import Path
def settreepermissions(root: Path, *, dirmode: int = 0o755, filemode: int = 0o644) -> None:
for path in root.rglob("*"):
try:
if path.is_symlink():
# Symlinks are tricky; leave them alone unless you have a reason.
continue
if path.is_dir():
os.chmod(path, dir_mode)
elif path.is_file():
os.chmod(path, file_mode)
except PermissionError as exc:
raise PermissionError(f"Failed chmod on {path}: {exc}") from exc
if name == "main":
build_root = Path("dist")
buildroot.mkdir(parents=True, existok=True)
(buildroot / "bin").mkdir(existok=True)
(buildroot / "bin" / "daily-report").writetext("#!/usr/bin/env sh\necho ok\n", encoding="utf-8")
(buildroot / "data").mkdir(existok=True)
(buildroot / "data" / "quarterly.csv").writetext("region,revenue\nNA,100\n", encoding="utf-8")
settreepermissions(build_root)
print("Set directory/file permissions under dist/")
If you need executable files inside bin/, you can extend this by detecting known script paths and adding +x for them.
Performance note
Recursively walking a tree is usually fast enough for build artifacts. On typical SSD-backed CI runners, setting permissions over a few thousand files is often in the “tens to hundreds of milliseconds” range. If you’re touching hundreds of thousands of files (monorepo checkouts, huge caches), it can jump to seconds. In those cases, narrow the scope: only chmod what you generate.
Special bits: setuid, setgid, and sticky (use with care)
Python’s stat module exposes special bits that change runtime behavior:
stat.S_ISUID(set-user-ID)stat.S_ISGID(set-group-ID)stat.S_ISVTX(sticky)
These are powerful and easy to misuse, so I’ll be direct:
- Don’t set
SISUIDorSISGIDin application code unless you truly know why. In many environments they’re restricted, and they can introduce privilege escalation risks if misapplied.
Sticky bit for shared directories
The sticky bit is the one I actually see in “normal” operational setups. A classic example is a shared drop folder where everyone can create files, but only the owner (or root) can delete their own files.
Here’s a script that prepares a shared temp directory:
from future import annotations
import os
import stat
from pathlib import Path
def makeshareddropbox(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
# World-writable + sticky: similar to /tmp behavior on many systems
mode = 0o777 | stat.S_ISVTX
os.chmod(path, mode)
if name == "main":
shared_path = Path("shared/dropbox")
makeshareddropbox(shared_path)
print(f"Prepared shared directory: {shared_path}")
On a Unix system, ls -ld shared/dropbox should show a t in the others execute position (something like drwxrwxrwt).
Symlinks, file descriptors, and race conditions: the details that matter
Once you’re writing automation that runs with elevated permissions (or handles untrusted paths), chmod becomes security-sensitive.
Symlinks: do you mean the link or the target?
By default, many Unix chmod operations affect the target of a symlink, not the symlink itself. Python exposes a control in some builds:
os.chmod(path, mode, follow_symlinks=False)
If you’re operating on paths that could be influenced by an attacker (think: temp directories, extracted archives, user-controlled workspaces), you should be very cautious about symlink tricks.
A safer pattern is operating on file descriptors when possible:
- open the file safely
- call
os.fchmod(fd, mode)
That reduces time-of-check/time-of-use issues.
from future import annotations
import os
from pathlib import Path
def chmodopenedfile(path: Path, mode: int) -> None:
# Open first, then chmod via file descriptor.
# This is a safer pattern when you care about races.
with open(path, "rb") as f:
os.fchmod(f.fileno(), mode)
if name == "main":
p = Path("artifacts/release-notes.txt")
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("Release notes\n", encoding="utf-8")
chmodopenedfile(p, 0o644)
print("Set permissions via fchmod")
umask interaction
If you create files and then immediately call chmod, your final permissions are exactly what you set (subject to filesystem and policy constraints). But if you rely on defaults, umask can silently remove bits at creation time. In CI and containers, umask values vary.
My practice:
- For secrets: create file, then
chmod 0o600immediately. - For generated scripts: write file, then
chmodto a known baseline, then add execute.
Filesystems and containers can block permission changes
Some filesystems (or mount options) won’t honor Unix permission bits the way you expect:
- Windows-mounted volumes in Docker Desktop
- Certain network filesystems
- Read-only mounts
- Corporate endpoint security policies
In those cases, os.chmod() may raise PermissionError or it may succeed but not produce the expected behavior. Treat permissions as part of your deployment environment contract, not just your code.
When you should (and should not) reach for os.chmod()
I like being opinionated here because it saves time.
You should use os.chmod() when:
- You generate files that must not be world-readable (tokens, credentials, private keys).
- You ship scripts/binaries and need the execute bit set in a reproducible way.
- You create directories meant for collaboration with a specific permission policy.
- You build packaging/install steps where permissions must be correct at install time.
You should avoid os.chmod() when:
- You’re trying to implement real authorization on Windows (use ACL tooling instead).
- You don’t control the filesystem semantics (some mounts ignore bits).
- You’re writing a library function that changes permissions of user data unexpectedly.
If you’re writing a library: prefer returning a clear error that tells the caller what permissions are required, rather than changing their files behind their back.
Common mistakes I see (and how I prevent them)
These are the issues I’ve debugged repeatedly across teams.
Mistake 1: Treating chmod as additive
Fix: read current mode and modify bits.
I like to keep a tiny helper around so I don’t re-derive the same masking logic every time:
from future import annotations
import os
import stat
from pathlib import Path
def chmodaddbits(path: Path, bitstoadd: int) -> None:
current = stat.SIMODE(os.stat(path).stmode)
os.chmod(path, current | bitstoadd)
def chmodremovebits(path: Path, bitstoremove: int) -> None:
current = stat.SIMODE(os.stat(path).stmode)
os.chmod(path, current & ~bitstoremove)
if name == "main":
p = Path("scripts/example.sh")
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("#!/usr/bin/env sh\necho hi\n", encoding="utf-8")
os.chmod(p, 0o644)
chmodaddbits(p, stat.S_IXUSR)
chmodremovebits(p, stat.S_IWOTH)
The key is that I always treat os.chmod() as “write the whole permissions value,” so I compute that value explicitly.
Mistake 2: Forgetting that directories need x to be usable
Fix: when setting directory permissions, include +x for whoever needs access.
A directory with mode 0o644 is basically a broken door: it lets you list names (r) and maybe change metadata (w), but it doesn’t let you traverse (x), which is the thing most people mean when they say “can access the directory.”
If I’m making a directory intended to be readable, I default to 0o755 (or 0o750 for team-only), not 0o644.
Mistake 3: Confusing “execute” with “I can run it”
Fix: remember that “execute” requires more than the x bit.
Even with +x, execution can fail for other reasons:
- The script has no shebang (e.g.,
#!/usr/bin/env python3) and isn’t a binary. - The interpreter referenced in the shebang doesn’t exist on the target machine.
- The filesystem is mounted
noexec. - The file is a text file with Windows line endings and the shebang is malformed.
When I’m debugging “works locally, fails on server,” I check permissions, then shebang/interpreter, then mount options.
Mistake 4: Setting permissions on the wrong path
Fix: log what you chmod, and validate with a stat read-back.
This sounds obvious, but in real automation you might have multiple directories, symlinks, temp paths, or build staging roots. If I’m writing a script that chmods something important, I usually add a sanity check:
from future import annotations
import os
import stat
from pathlib import Path
def ensuremodeexact(path: Path, expected_mode: int) -> None:
os.chmod(path, expected_mode)
actual = stat.SIMODE(os.stat(path).stmode)
if actual != expected_mode:
raise RuntimeError(f"Expected {oct(expected_mode)} on {path}, got {oct(actual)}")
if name == "main":
p = Path("output/config.ini")
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("[app]\nname=demo\n", encoding="utf-8")
ensuremodeexact(p, 0o644)
On filesystems that ignore Unix permission bits, this check will save you from a false sense of correctness.
Mistake 5: Wiping special bits accidentally
Fix: if you must preserve special bits, preserve them intentionally.
Most application code shouldn’t touch setuid/setgid/sticky bits at all. But occasionally you’re operating on a directory where S_ISGID is used to enforce group inheritance (common for team-shared directories). If you “set exact mode” without preserving, you might break the intended policy.
If I need to preserve special bits on a directory while normalizing read/write/execute bits, I do it explicitly:
from future import annotations
import os
import stat
from pathlib import Path
def normalizedirpermspreservespecial(path: Path, basedirperms: int) -> None:
st = os.stat(path)
current = st.st_mode
# Special bits we might want to preserve
special = current & (stat.SISUID
stat.S_ISVTX)
os.chmod(path, basedirperms | special)
if name == "main":
d = Path("shared/team")
d.mkdir(parents=True, exist_ok=True)
normalizedirpermspreservespecial(d, 0o775)
This is a “sharp tool” pattern; I only use it when I can articulate why those special bits exist.
Inspecting permissions in Python (and mapping to ls -l)
When I’m teaching this, I try to remove the mystery: you should be able to look at a path, print its mode, and reason about it.
Read the current permissions
At minimum, I want these two pieces:
- the raw
st_mode(includes file type) - the permission bits only (what
chmodmanipulates)
from future import annotations
import os
import stat
from pathlib import Path
def describe(path: Path) -> str:
st = os.stat(path)
perms = stat.SIMODE(st.stmode)
return f"path={path} perms={oct(perms)}"
if name == "main":
p = Path("example.txt")
p.write_text("hello\n", encoding="utf-8")
print(describe(p))
oct(perms) gives you something like 0o644, which is usually the fastest mental decode.
Render -rwxr-xr-- style text
If I need a human-friendly string (similar to ls -l), I use stat.filemode():
import os
import stat
from pathlib import Path
def filemode_string(path: Path) -> str:
st = os.stat(path)
return stat.filemode(st.st_mode)
if name == "main":
p = Path("example.txt")
p.write_text("hello\n", encoding="utf-8")
print(filemode_string(p))
That prints something like -rw-r--r--. This is extremely handy when you’re logging permission issues in automation.
Translating between octal and rwx
If you’re new to octal modes, here’s the clean mental trick:
- Each “row” (owner/group/others) is a 3-bit number.
r=4,w=2,x=1.- Add them up per row.
Examples:
7=rwx(4+2+1)6=rw-(4+2)5=r-x(4+1)4=r--(4)
So 0o755 is:
- owner
7→rwx - group
5→r-x - others
5→r-x
And 0o640 is:
- owner
6→rw- - group
4→r-- - others
0→---
When I’m writing code, I tend to use octal for “known final states” and stat bit masks for “surgical changes.”
Practical scenarios I actually use os.chmod() for
This is the part that turns a reference function into a tool you’ll reach for confidently.
Scenario 1: Make generated scripts executable in a build step
If a build step generates a script file, I don’t rely on git file mode or a developer remembering chmod +x. I set it deliberately.
from future import annotations
import os
import stat
from pathlib import Path
def writeexecutablescript(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
# Start from a conservative baseline then add execute.
os.chmod(path, 0o644)
current = stat.SIMODE(os.stat(path).stmode)
os.chmod(path, current
stat.SIXGRPstat.S_IXOTH)
if name == "main":
script = Path("dist/bin/hello")
writeexecutablescript(script, "#!/usr/bin/env sh\necho hello\n")
Note what I did there: I made it readable to all (0o644) and executable to all (+111), which ends up effectively 0o755 for most scripts. If that’s too permissive for your environment, choose owner-only execute (stat.S_IXUSR) and keep it 0o744 or 0o700.
Scenario 2: Lock down secrets immediately (and fail loudly)
For secrets, I’m strict. If I can’t set permissions properly, I want to know, because the alternative is “it exists, and might be readable.”
from future import annotations
import os
import stat
from pathlib import Path
def writesecretfile(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
os.chmod(path, 0o600)
actual = stat.SIMODE(os.stat(path).stmode)
if actual != 0o600:
raise RuntimeError(f"Secret file permissions not enforced on {path}: got {oct(actual)}")
if name == "main":
writesecretfile(Path(".secrets/db_password"), "super-secret\n")
If this trips in CI on a weird filesystem, I don’t “paper over” it. I either change where secrets are stored (inside the container filesystem rather than a mounted volume) or I switch to a secret manager.
Scenario 3: Prepare a shared team directory with predictable behavior
If you want a group-shared directory where new files inherit the group, you typically use the setgid bit on the directory and a group-friendly permission mode.
I’ll say it plainly: this is operationally useful, but it’s also an area where your org’s policies (and default umask) matter a lot.
A minimal example just demonstrating the chmod part:
from future import annotations
import os
import stat
from pathlib import Path
def prepareteamdir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
# Group-write + setgid on directory.
# This does not change ownership; you still need correct group ownership.
os.chmod(path, 0o2775)
# Validate: permission bits include setgid.
mode = os.stat(path).st_mode
if not (mode & stat.S_ISGID):
raise RuntimeError(f"Expected setgid bit on {path}")
if name == "main":
prepareteamdir(Path("shared/team"))
I only do this in environments where group ownership is managed intentionally (e.g., on a server with a known group) because otherwise it turns into “works on one machine, confusing on another.”
Archives, packaging, and why permissions often get lost
A lot of real-world permission bugs show up when files move across boundaries:
- checked out from git on one OS and deployed to another
- extracted from a
.zip - copied through a build container onto a mounted volume
- produced by a tool that doesn’t preserve mode bits
If you’re packaging executables or scripts, you want to decide where permissions are “authoritative.” My preference:
- During artifact creation, normalize permissions for the artifact’s tree.
- During install/unpack, re-apply permissions if the archive format or destination can lose them.
Some formats preserve Unix modes better than others. Some pipelines strip them unintentionally. So rather than trusting the universe, I put a small “permission normalization” step at the end of builds.
Error handling: what to do when chmod fails
In production scripts, chmod failures usually fall into a few buckets.
PermissionError
Typically one of:
- you don’t own the file and aren’t privileged
- the directory doesn’t allow changes (permissions or ACL)
- you’re on a read-only filesystem
My rule: if I’m chmodding something I just created, PermissionError is a bug or an environment contract violation, and I fail loudly.
If I’m chmodding existing user-controlled paths (like a tool that “fixes permissions”), I treat it as best-effort and report a summary.
FileNotFoundError
Often indicates a race (another process removed it) or a path mismatch. For build steps, I treat it as fatal because it likely means I’m operating in the wrong place.
“It succeeded but didn’t change anything”
This is the tricky one. Some mounts ignore mode changes. That’s why I like optional “read back and validate” checks for high-stakes files.
Alternatives and complements to os.chmod()
Sometimes chmod is the right tool, sometimes it’s only part of the solution.
Use umask as a default policy (but don’t depend on it)
In long-running processes or controlled environments, a reasonable umask can prevent accidentally creating world-readable files. But I don’t like relying on it for correctness, because:
- CI runners differ
- container entrypoints differ
- developers’ shells differ
So I treat umask as a safety net, not the primary mechanism.
Use ownership and groups for collaboration
If your goal is “only team members can edit these files,” permission bits alone are not enough unless group ownership is correct.
That’s why I think of chmod as “what can the owning user/group/others do,” but chown/chgrp (or admin-managed ownership) is often what makes the policy real.
Use ACLs when the model doesn’t fit
When you need more granular control than owner/group/others (especially on Windows), ACLs are usually the better model.
I won’t go deep into ACL APIs here, but my decision rule is simple:
- If you can express your policy with owner/group/others and special bits, Unix modes are great.
- If you need per-user exceptions or complex sharing rules, you’re probably in ACL territory.
Windows notes: what os.chmod() can and can’t do
It’s tempting to write one cross-platform “set permissions” function and call it a day. In practice:
- On Unix,
chmodis fundamental. - On Windows,
chmodis not the primary permission system.
What I do in cross-platform Python tooling:
- If the permission is about “secrets shouldn’t be world-readable,” I still try to use
os.chmod(path, 0o600)on Windows, but I treat it as “best effort,” and I document that real enforcement depends on ACLs and the environment. - If the permission is about “must be executable,” I avoid relying on chmod on Windows entirely. Executability there is not controlled by the Unix execute bit; it’s about file extensions and the shell/loader.
In other words: I write cross-platform code that behaves correctly on Unix, and behaves sensibly (without false promises) on Windows.
A troubleshooting checklist I actually follow
When something fails and I suspect permissions, I walk this order:
1) Confirm the path is what I think it is
– Print the absolute path.
– Check for symlinks (Path.is_symlink()).
2) Log the current mode in two formats
– oct(stat.SIMODE(os.stat(p).stmode))
– stat.filemode(os.stat(p).st_mode)
3) Check the directory permissions too
– A file can be 644, but if the directory doesn’t have execute for you, you’re blocked.
4) Check mount options / filesystem type
– Especially inside containers or when using mounted volumes.
5) If it’s execution, verify shebang/interpreter and line endings
– Permissions can be perfect and it still won’t run.
6) Validate after chmod when it matters
– Read back the mode bits.
This checklist is boring—which is exactly why I like it.
Putting it together: a small “permission policy” helper
When I’m working on a real project, I tend to centralize permission logic so it’s not scattered across scripts. Here’s a compact helper module pattern:
from future import annotations
import os
import stat
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class PermissionPolicy:
dir_mode: int = 0o755
file_mode: int = 0o644
secret_mode: int = 0o600
def apply_tree(self, root: Path) -> None:
for path in root.rglob("*"):
if path.is_symlink():
continue
if path.is_dir():
os.chmod(path, self.dir_mode)
elif path.is_file():
os.chmod(path, self.file_mode)
def makeexecutable(self, path: Path, *, forall: bool = True) -> None:
current = stat.SIMODE(os.stat(path).stmode)
bits = stat.S_IXUSR
if for_all:
bits
stat.SIXOTH
os.chmod(path, current | bits)
def lockdownsecret(self, path: Path) -> None:
os.chmod(path, self.secret_mode)
actual = stat.SIMODE(os.stat(path).stmode)
if actual != self.secret_mode:
raise RuntimeError(f"Secret permissions not applied on {path}: {oct(actual)}")
if name == "main":
policy = PermissionPolicy()
dist = Path("dist")
dist.mkdir(exist_ok=True)
(dist / "bin").mkdir(exist_ok=True)
tool = dist / "bin" / "tool"
tool.write_text("#!/usr/bin/env sh\necho tool\n", encoding="utf-8")
policy.apply_tree(dist)
policy.make_executable(tool)
Centralizing it like this gives me three wins:
- The modes are defined once (easy to review).
- Changes are consistent (no “this script uses 777 for some reason”).
- It becomes testable (even if tests are just “read back the bits”).
Closing thought: permissions are part of correctness
When I write automation now, I treat permissions the same way I treat configuration files, environment variables, and network ports: they’re not “ops magic,” they’re a concrete part of whether the system is correct.
os.chmod() is deceptively simple—just two arguments—but it sits at the boundary between your code and the operating system’s access model. Once you understand that it sets the mode you pass (and that directories and symlinks have sharp edges), it becomes a reliable, boring tool. And boring is exactly what you want from a function that decides whether your scripts run and your secrets stay private.


