tcc-venv
Give a uv/venv Python app a stable, codesigned macOS TCC identity — so Full Disk
Access / Automation grants survive uv sync and Python upgrades, and the permission
dialog shows a recognizable per-project name instead of python3.12.
macOS only for the privacy benefit. On Linux/Windows it degrades to a no-op passthrough so the same launcher name works everywhere.
Why macOS only? The privacy-grant-churn problem is specific to macOS TCC. The nearest analogues elsewhere are path-keyed, not signature-keyed: Windows Defender Firewall matches its allow rules by image path, and Linux AppArmor/SELinux confine by path — so a moving venv-python path can drift out from under those too. But the fix there is simply a stable launcher path, which the no-op
python-tccsymlink already gives you on every platform; there is no codesigning, disclaim bootstrap, or privacy prompt involved off macOS.
The problem
macOS TCC (the privacy system behind Full Disk Access, Automation, Calendar,
etc.) grants permissions to a specific binary identity — its path plus its
code-signing identity. A Python interpreter created by uv lives at a churning,
version-pinned path:
~/.local/share/uv/python/cpython-3.12.7-macos-aarch64-none/bin/python3.12
Grant that binary Full Disk Access, then run uv python upgrade or let uv sync
pull a new patch release, and the path changes. To TCC it's now a different app:
the grant silently stops applying and your tool starts getting "Operation not
permitted" until you re-grant it by hand. The dialog also just says "python3.12",
giving the user no idea which project is asking.
tcc-venv fixes both by interposing a tiny signed C launcher with a stable
identity that you grant once:
<venv>/bin/python-tcc-<project> # signed trampoline — the stable TCC identity
<venv>/bin/python-tcc # -> python-tcc-<project> (uniform name for shebangs/control)
The launcher self-locates its venv, spawns <venv>/bin/python with your arguments,
forwards signals, and propagates the exit code — staying the parent process so TCC
attributes everything to it. To make that hold even when the launcher above it does
not hand it a TCC identity (a GUI app, a terminal — anything that isn't launchd), it
re-spawns a self-responsible copy of itself first (a "disclaim" bootstrap), so the
stable signed identity owns the grant regardless of who started it. Re-running wrap
after uv sync restores the identical signed bytes from a local cache, so the
cdhash — and the grant — is unchanged.
Set TCC_VENV_CHDIR=1 to run from the project root (the venv's parent), or to a path
to cd there — handy when the launcher gives an unpredictable working directory.
What it is not
It does not bypass or weaken TCC. You still grant access explicitly in System Settings; this only stops the identity from moving out from under that grant. It relies on undocumented TCC responsible-process inheritance and ad-hoc cdhash determinism, both of which can change across macOS releases — treat it as unofficial and verify on the macOS versions you ship to.
Tested: on macOS Darwin 25.5.0, both Full Disk Access and EventKit/Reminders
access through a launchd-launched (no terminal ancestor) python-tcc-<project>
attach the grant to that signed binary — TCC records it against the per-project
binary path, not the python child — confirming the responsible-process inheritance
this tool depends on. Re-confirm on the macOS versions you target.
Requirements
- macOS with the Xcode Command Line Tools (
xcode-select --install) — the trampoline is compiled withccclient-side atwraptime. - Python ≥ 3.11.
- A venv with a
pyvenv.cfg(uv,python -m venv, pdm-in-venv mode — all fine).
Install
uvx tcc-venv wrap # run without installing
# or
pipx install tcc-venv
# or
uv tool install tcc-venv
Usage
Run wrap once per venv, then point your launchers / shebangs at python-tcc.
With uv
cd my-project
uv sync # creates ./.venv
uvx tcc-venv wrap # defaults to ./.venv
Because uv sync recreates the interpreter, re-run wrap after a sync — it restores
the same identity from cache, so you do not re-grant:
uv sync && uvx tcc-venv wrap
With pdm
pdm can manage an in-project venv. Enable it, install, then wrap:
pdm config python.use_venv true
pdm install # creates ./.venv
uvx tcc-venv wrap .venv
With a bare venv
python3 -m venv .venv
.venv/bin/pip install -e .
uvx tcc-venv wrap .venv
Tip: python -m venv --prompt myapp .venv sets the friendly name used in the
identity and the TCC dialog (otherwise the venv's parent directory name is used).
Grant the permission (once)
After wrap, the binary path is printed. Add that binary to the relevant pane:
System Settings → Privacy & Security → Full Disk Access → + → select
<venv>/bin/python-tcc-<project>
Automation / Calendar / Reminders prompts appear on first use. From then on, run your app through the shim:
.venv/bin/python-tcc my_app.py
# or in a shebang: #!/path/to/.venv/bin/python-tcc
Two ways to launch: wrap vs run
wrap + invoke the binary directly — leanest, for long-lived servers. Point the
launcher straight at <venv>/bin/python-tcc-<project>; zero per-launch overhead,
offline-safe. (Re-run wrap after uv sync.)
tcc-venv run — wrap-on-demand, then run anything under the identity. It builds and
signs the launcher if missing, then execs your command as the TCC-responsible parent.
The command can be uv run … (the launcher disclaims first, so identity still attaches
to the stable binary), so you get uv's auto-sync without losing the grant:
tcc-venv run --cd-to-project uv run --frozen my_app.py
# or a module: tcc-venv run -m my_app
# in a shebang (note env -S for the multi-token line):
#!/usr/bin/env -S uvx tcc-venv run --cd-to-project uv run --frozen
run discovers the nearest .venv from the cwd; pass --venv DIR for split layouts or
an unpredictable cwd. --cd-to-project runs from the venv's parent. The convenience
costs uvx/uv startup per launch — use the direct-binary path when latency matters.
Commands
| Command | What it does |
|---|---|
tcc-venv wrap [venv ...] |
Install/refresh the launcher (idempotent). Defaults to ./.venv. Accepts multiple venvs. |
tcc-venv wrap --rebuild |
Force a fresh build + sign (new cdhash — you'll need to re-grant FDA). |
tcc-venv run [--cd-to-project] [--venv DIR] CMD… |
Wrap-on-demand, then run CMD under the stable identity (e.g. uv run …). |
tcc-venv status [venv] |
Show the installed shim, target, cdhash, and expected identifier. |
Both commands accept --identifier-prefix PREFIX (default local.tcc-venv).
The identifier is <prefix>.<project>.<hash8>, where <prefix> defaults to
local.tcc-venv (override with --identifier-prefix or $TCC_VENV_IDENTIFIER_PREFIX)
and <hash8> is derived from the venv's real path so two projects with the same name
never share a grant.
How it works (short version)
- Compile
trampoline.conce per architecture (cached, unsigned). - Copy it to
<venv>/bin/python-tcc-<project>and ad-hoc codesign it with a stable--identifier. - Cache the signed bytes by
identifier+ source tag; on re-wrap, copy them back so the cdhash is byte-identical regardless of codesign version drift. (Upgradingtcc-venvto a changed trampoline changes the tag → new cdhash → re-grant once.) - Symlink
python-tcc -> python-tcc-<project>.
Steps 1–3 are safe to run concurrently: several launchd daemons sharing a venv all start in the same second at boot, so each invocation stages into a private temp file, swaps the shim with an atomic rename, and skips the work entirely when the installed binary is already correct. (Before 0.2.2 they shared a fixed staging filename and deleted each other's, so the loser died — see the changelog.)
At runtime the trampoline resolves its own path → venv, refuses to run if a stray
$VIRTUAL_ENV disagrees, re-spawns a self-responsible copy of itself (disclaim
bootstrap) so its identity owns the grant under any launcher, optionally cds per
$TCC_VENV_CHDIR, posix_spawns the venv's python, forwards signals, and returns
the child's exit status (128 + signo on signal death). On non-macOS it's a plain
exec of the venv python with no signing.
See AGENTS.md for the full design and invariants.
Changelog
0.2.2
Fixes a race between concurrent invocations. Every staging path was a fixed filename
removed in a finally:, so two overlapping runs deleted each other's half-written
file and the loser exited with a traceback — which cost a real boot when two launchd
daemons sharing a venv started in the same second. Three sites were affected: the
per-venv install, the machine-wide compile cache (where the racing peer is an
unrelated project, not another run against your venv), and the signed-bytes cache.
Also: python-tcc is now swapped with an atomic rename instead of unlink-then-create,
which previously left a window in which the shim did not exist at all, and an
already-correct install now short-circuits without writing anything.
No re-grant needed. This release does not touch trampoline.c, so the source tag,
the signed-bytes cache key and the cdhash are all unchanged — existing Full Disk
Access / Automation grants keep working.
License
MIT — see LICENSE.
Release files for tcc-venv 0.2.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tcc_venv-0.2.2.tar.gz | 31.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tcc_venv-0.2.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 51.7 kB
Release files / tcc_venv-0.2.2.tar.gz
| Download URL | tcc_venv-0.2.2.tar.gz |
|---|---|
| Size | 31.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
04834e54c3e105b39d25b1b7afbb9c1d8530f37356aa926a3dcbb701577fb539
|
|
BLAKE2b-256 checksum How to use checksums |
f7776cbf52cdc8ebe7e632fcfa7a183a5f1a058052797ed5bc2331b2a12512d3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 31, 2026.
Transparency logRelease files / tcc_venv-0.2.2-py3-none-any.whl
| Download URL | tcc_venv-0.2.2-py3-none-any.whl |
|---|---|
| Size | 20.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f715b4ec684070e17480140cd198f416f86c154db557ed246e9d9da5e39c5a28
|
|
BLAKE2b-256 checksum How to use checksums |
f344b4561e4cd4e8986395b910d3f16de380421ae9808c6f01529b9c27013e4f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 31, 2026.
Transparency log