tcc-venv 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ name: Publish to PyPI
2
+
3
+ # Mirrors mo22/reslock's release path: a published GitHub release (tag vX.Y.Z)
4
+ # triggers a build + PyPI trusted publishing via OIDC (no stored token).
5
+ # The `pypi` environment's trusted publisher only authorizes on `release` events,
6
+ # so workflow_dispatch builds but cannot publish.
7
+
8
+ on:
9
+ release:
10
+ types: [published]
11
+ workflow_dispatch:
12
+
13
+ jobs:
14
+ build:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v6
18
+ - uses: astral-sh/setup-uv@v8.0.0
19
+ - run: uv build
20
+
21
+ - uses: actions/upload-artifact@v7
22
+ with:
23
+ name: dist
24
+ path: dist/
25
+
26
+ publish:
27
+ needs: build
28
+ runs-on: ubuntu-latest
29
+ environment: pypi
30
+ permissions:
31
+ id-token: write
32
+ steps:
33
+ - uses: actions/download-artifact@v8
34
+ with:
35
+ name: dist
36
+ path: dist/
37
+
38
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,19 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .eggs/
8
+
9
+ # venv
10
+ .venv/
11
+ venv/
12
+
13
+ # Claude / agent local state
14
+ .claude/settings.local.json
15
+ .claude/.claude_state.json
16
+ .claude/*.lock
17
+
18
+ # macOS
19
+ .DS_Store
@@ -0,0 +1,89 @@
1
+ # AGENTS.md — tcc-venv
2
+
3
+ Project-level instructions and design notes for agents working in this repo.
4
+ Tool-agnostic; `CLAUDE.md` is a symlink to this file.
5
+
6
+ ## What this is
7
+
8
+ `tcc-venv` installs a stable, codesigned **macOS TCC launcher** into a uv/venv so
9
+ Full Disk Access / Automation grants survive `uv sync` / Python upgrades and show a
10
+ per-project name in the permission dialog.
11
+
12
+ The core problem: macOS TCC keys privacy grants on the *binary identity* (path +
13
+ code-signing identity / cdhash) of the responsible process. A uv/venv `python` lives
14
+ at a version-pinned, churning path (`.../cpython-3.12.x-macos.../bin/python3.12`),
15
+ so every interpreter upgrade looks like a *different* app to TCC and silently drops
16
+ the grant. `tcc-venv` interposes a tiny signed C launcher with a **stable identity**
17
+ that the user grants once.
18
+
19
+ ## Layout
20
+
21
+ ```
22
+ src/tcc_venv/
23
+ cli.py # the `tcc-venv` CLI: wrap / status. build + codesign + cache logic.
24
+ trampoline.c # the signed launcher. self-locates its venv, spawns python, forwards signals.
25
+ __init__.py
26
+ pyproject.toml # hatchling; force-includes trampoline.c into the wheel.
27
+ ```
28
+
29
+ ## How it works
30
+
31
+ `tcc-venv wrap <venv>` (macOS):
32
+
33
+ 1. Resolve the venv (`pyvenv.cfg` must exist).
34
+ 2. Derive a friendly name (`pyvenv.cfg` `prompt`, else parent dir) and a
35
+ collision-resistant identifier `<prefix>.<name>.<sha256(realpath)[:8]>`, where
36
+ `<prefix>` defaults to `local.tcc-venv` and is overridable via
37
+ `--identifier-prefix` / `$TCC_VENV_IDENTIFIER_PREFIX`.
38
+ 3. Compile `trampoline.c` once per arch → cached unsigned binary.
39
+ 4. Install it as `<venv>/bin/python-tcc-<name>`, ad-hoc codesign with
40
+ `--identifier <identifier>`, and cache the **signed bytes** keyed by identifier.
41
+ 5. Symlink `<venv>/bin/python-tcc -> python-tcc-<name>` (the uniform name used in
42
+ shebangs / process control).
43
+
44
+ On re-wrap (after `uv sync` blows the binary away), the **signed bytes are copied
45
+ back** from cache, so the cdhash — and therefore the TCC grant — is identical by
46
+ construction, immune to codesign-version drift.
47
+
48
+ The trampoline:
49
+ - self-locates its venv from its own executable path (`_NSGetExecutablePath` +
50
+ `realpath`), **never** trusts `$VIRTUAL_ENV` to pick the interpreter — it only
51
+ *refuses to run* if `$VIRTUAL_ENV` disagrees with the self-located venv.
52
+ - `posix_spawn`s `<venv>/bin/python` (falls back to `python3`) and stays the live
53
+ parent (must NOT exec into python, or the child becomes the responsible process
54
+ and loses the grant).
55
+ - runs the child in its **own process group** and, when interactive, hands it the
56
+ controlling terminal (`tcsetpgrp`) so terminal-generated signals reach the child
57
+ once — not the wrapper-then-child twice. Forwarded signals are blocked across
58
+ the spawn, and the child is started with a default mask + dispositions
59
+ (`POSIX_SPAWN_SETSIGMASK | SETSIGDEF`) so it actually receives them.
60
+ - forwards SIGINT/TERM/HUP/QUIT/USR1/USR2 (directed at the wrapper) to the child's
61
+ process group, propagates exit status (`128 + signo` on signal death).
62
+
63
+ On non-macOS the installer just symlinks `python-tcc -> python` (pure passthrough;
64
+ the C file still compiles to a plain `execv`).
65
+
66
+ ## Design invariants (don't regress these)
67
+
68
+ - **Bytes are project-independent.** Identity comes from `codesign --identifier` +
69
+ the on-disk filename, not from the compiled bytes. One cached build → every venv.
70
+ - **Determinism over re-signing.** Always prefer copy-back of cached signed bytes;
71
+ only `--rebuild` mints a new cdhash (and forces re-granting FDA).
72
+ - **Self-locate, never `$VIRTUAL_ENV`-redirect.** A leaked `$VIRTUAL_ENV` must never
73
+ make this binary run another project's interpreter under this identity.
74
+ - **Stay the parent.** Spawn + wait, don't exec, so TCC inheritance holds.
75
+
76
+ ## Status / caveats
77
+
78
+ This relies on **undocumented** TCC responsible-process inheritance and ad-hoc
79
+ cdhash determinism that *can* drift across macOS / codesign versions. It does **not**
80
+ bypass TCC — the user still grants explicitly in System Settings; it only stabilizes
81
+ the identity. Treat as unofficial; verify on the macOS versions you ship to.
82
+
83
+ Open release work is tracked in `tasks/release-public.md`.
84
+
85
+ ## Conventions
86
+
87
+ - Format Python with `uvx ruff format` / `uvx ruff check --fix`.
88
+ - No runtime deps (stdlib only). Keep it that way — this is a tiny tool.
89
+ - The trampoline must stay warning-clean under `-Wall -Wextra`.
@@ -0,0 +1 @@
1
+ AGENTS.md
tcc_venv-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moritz Möller
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: tcc-venv
3
+ Version: 0.1.0
4
+ Summary: Install a stable, codesigned macOS TCC launcher (python-tcc) into a uv/venv
5
+ Project-URL: Homepage, https://github.com/mo22/tcc-venv
6
+ Project-URL: Repository, https://github.com/mo22/tcc-venv
7
+ Project-URL: Issues, https://github.com/mo22/tcc-venv/issues
8
+ Author-email: Moritz Möller <mm@mxs.de>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: codesign,full-disk-access,macos,tcc,uv,venv
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: MacOS X
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: MacOS :: MacOS X
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development :: Build Tools
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+
21
+ # tcc-venv
22
+
23
+ **Give a uv/venv Python app a stable, codesigned macOS TCC identity** — so Full Disk
24
+ Access / Automation grants survive `uv sync` and Python upgrades, and the permission
25
+ dialog shows a recognizable per-project name instead of `python3.12`.
26
+
27
+ > macOS only for the privacy benefit. On Linux/Windows it degrades to a no-op
28
+ > passthrough so the same launcher name works everywhere.
29
+
30
+ ## The problem
31
+
32
+ macOS TCC (the privacy system behind *Full Disk Access*, *Automation*, *Calendar*,
33
+ etc.) grants permissions to a specific **binary identity** — its path plus its
34
+ code-signing identity. A Python interpreter created by `uv` lives at a churning,
35
+ version-pinned path:
36
+
37
+ ```
38
+ ~/.local/share/uv/python/cpython-3.12.7-macos-aarch64-none/bin/python3.12
39
+ ```
40
+
41
+ Grant *that* binary Full Disk Access, then run `uv python upgrade` or let `uv sync`
42
+ pull a new patch release, and the path changes. To TCC it's now a **different app**:
43
+ the grant silently stops applying and your tool starts getting "Operation not
44
+ permitted" until you re-grant it by hand. The dialog also just says "python3.12",
45
+ giving the user no idea which project is asking.
46
+
47
+ `tcc-venv` fixes both by interposing a tiny **signed C launcher** with a stable
48
+ identity that you grant *once*:
49
+
50
+ ```
51
+ <venv>/bin/python-tcc-<project> # signed trampoline — the stable TCC identity
52
+ <venv>/bin/python-tcc # -> python-tcc-<project> (uniform name for shebangs/control)
53
+ ```
54
+
55
+ The launcher self-locates its venv, spawns `<venv>/bin/python` with your arguments,
56
+ forwards signals, and propagates the exit code — staying the parent process so TCC
57
+ attributes everything to *it*. Re-running `wrap` after `uv sync` restores the
58
+ **identical** signed bytes from a local cache, so the cdhash — and the grant — is
59
+ unchanged.
60
+
61
+ ### What it is *not*
62
+
63
+ It does **not** bypass or weaken TCC. You still grant access explicitly in System
64
+ Settings; this only stops the identity from moving out from under that grant. It
65
+ relies on *undocumented* TCC responsible-process inheritance and ad-hoc cdhash
66
+ determinism, both of which can change across macOS releases — treat it as unofficial
67
+ and verify on the macOS versions you ship to.
68
+
69
+ ## Requirements
70
+
71
+ - macOS with the **Xcode Command Line Tools** (`xcode-select --install`) — the
72
+ trampoline is compiled with `cc` client-side at `wrap` time.
73
+ - Python ≥ 3.11.
74
+ - A venv with a `pyvenv.cfg` (uv, `python -m venv`, pdm-in-venv mode — all fine).
75
+
76
+ ## Install
77
+
78
+ ```bash
79
+ uvx tcc-venv wrap # run without installing
80
+ # or
81
+ pipx install tcc-venv
82
+ # or
83
+ uv tool install tcc-venv
84
+ ```
85
+
86
+ ## Usage
87
+
88
+ Run `wrap` once per venv, then point your launchers / shebangs at `python-tcc`.
89
+
90
+ ### With uv
91
+
92
+ ```bash
93
+ cd my-project
94
+ uv sync # creates ./.venv
95
+ uvx tcc-venv wrap # defaults to ./.venv
96
+ ```
97
+
98
+ Because `uv sync` recreates the interpreter, re-run `wrap` after a sync — it restores
99
+ the same identity from cache, so you do **not** re-grant:
100
+
101
+ ```bash
102
+ uv sync && uvx tcc-venv wrap
103
+ ```
104
+
105
+ ### With pdm
106
+
107
+ pdm can manage an in-project venv. Enable it, install, then wrap:
108
+
109
+ ```bash
110
+ pdm config python.use_venv true
111
+ pdm install # creates ./.venv
112
+ uvx tcc-venv wrap .venv
113
+ ```
114
+
115
+ ### With a bare venv
116
+
117
+ ```bash
118
+ python3 -m venv .venv
119
+ .venv/bin/pip install -e .
120
+ uvx tcc-venv wrap .venv
121
+ ```
122
+
123
+ Tip: `python -m venv --prompt myapp .venv` sets the friendly name used in the
124
+ identity and the TCC dialog (otherwise the venv's parent directory name is used).
125
+
126
+ ## Grant the permission (once)
127
+
128
+ After `wrap`, the binary path is printed. Add **that** binary to the relevant pane:
129
+
130
+ > System Settings → Privacy & Security → Full Disk Access → **+** → select
131
+ > `<venv>/bin/python-tcc-<project>`
132
+
133
+ Automation / Calendar / Reminders prompts appear on first use. From then on, run your
134
+ app through the shim:
135
+
136
+ ```bash
137
+ .venv/bin/python-tcc my_app.py
138
+ # or in a shebang: #!/path/to/.venv/bin/python-tcc
139
+ ```
140
+
141
+ ## Commands
142
+
143
+ | Command | What it does |
144
+ | --- | --- |
145
+ | `tcc-venv wrap [venv ...]` | Install/refresh the launcher (idempotent). Defaults to `./.venv`. Accepts multiple venvs. |
146
+ | `tcc-venv wrap --rebuild` | Force a fresh build + sign (new cdhash — you'll need to re-grant FDA). |
147
+ | `tcc-venv wrap --identifier-prefix PREFIX` | Override the codesign identifier prefix (default `local.tcc-venv`). |
148
+ | `tcc-venv status [venv]` | Show the installed shim, target, cdhash, and expected identifier. |
149
+
150
+ The identifier is `<prefix>.<project>.<hash8>`, where `<prefix>` defaults to
151
+ `local.tcc-venv` (override with `--identifier-prefix` or `$TCC_VENV_IDENTIFIER_PREFIX`)
152
+ and `<hash8>` is derived from the venv's real path so two projects with the same name
153
+ never share a grant.
154
+
155
+ ## How it works (short version)
156
+
157
+ 1. Compile `trampoline.c` once per architecture (cached, unsigned).
158
+ 2. Copy it to `<venv>/bin/python-tcc-<project>` and ad-hoc codesign it with a stable
159
+ `--identifier`.
160
+ 3. Cache the **signed bytes** by identifier; on re-wrap, copy them back so the cdhash
161
+ is byte-identical regardless of codesign version drift.
162
+ 4. Symlink `python-tcc -> python-tcc-<project>`.
163
+
164
+ At runtime the trampoline resolves its own path → venv, refuses to run if a stray
165
+ `$VIRTUAL_ENV` disagrees, `posix_spawn`s the venv's `python`, forwards signals, and
166
+ returns the child's exit status (`128 + signo` on signal death). On non-macOS it's a
167
+ plain `exec` of the venv python with no signing.
168
+
169
+ See [`AGENTS.md`](AGENTS.md) for the full design and invariants.
170
+
171
+ ## License
172
+
173
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,153 @@
1
+ # tcc-venv
2
+
3
+ **Give a uv/venv Python app a stable, codesigned macOS TCC identity** — so Full Disk
4
+ Access / Automation grants survive `uv sync` and Python upgrades, and the permission
5
+ dialog shows a recognizable per-project name instead of `python3.12`.
6
+
7
+ > macOS only for the privacy benefit. On Linux/Windows it degrades to a no-op
8
+ > passthrough so the same launcher name works everywhere.
9
+
10
+ ## The problem
11
+
12
+ macOS TCC (the privacy system behind *Full Disk Access*, *Automation*, *Calendar*,
13
+ etc.) grants permissions to a specific **binary identity** — its path plus its
14
+ code-signing identity. A Python interpreter created by `uv` lives at a churning,
15
+ version-pinned path:
16
+
17
+ ```
18
+ ~/.local/share/uv/python/cpython-3.12.7-macos-aarch64-none/bin/python3.12
19
+ ```
20
+
21
+ Grant *that* binary Full Disk Access, then run `uv python upgrade` or let `uv sync`
22
+ pull a new patch release, and the path changes. To TCC it's now a **different app**:
23
+ the grant silently stops applying and your tool starts getting "Operation not
24
+ permitted" until you re-grant it by hand. The dialog also just says "python3.12",
25
+ giving the user no idea which project is asking.
26
+
27
+ `tcc-venv` fixes both by interposing a tiny **signed C launcher** with a stable
28
+ identity that you grant *once*:
29
+
30
+ ```
31
+ <venv>/bin/python-tcc-<project> # signed trampoline — the stable TCC identity
32
+ <venv>/bin/python-tcc # -> python-tcc-<project> (uniform name for shebangs/control)
33
+ ```
34
+
35
+ The launcher self-locates its venv, spawns `<venv>/bin/python` with your arguments,
36
+ forwards signals, and propagates the exit code — staying the parent process so TCC
37
+ attributes everything to *it*. Re-running `wrap` after `uv sync` restores the
38
+ **identical** signed bytes from a local cache, so the cdhash — and the grant — is
39
+ unchanged.
40
+
41
+ ### What it is *not*
42
+
43
+ It does **not** bypass or weaken TCC. You still grant access explicitly in System
44
+ Settings; this only stops the identity from moving out from under that grant. It
45
+ relies on *undocumented* TCC responsible-process inheritance and ad-hoc cdhash
46
+ determinism, both of which can change across macOS releases — treat it as unofficial
47
+ and verify on the macOS versions you ship to.
48
+
49
+ ## Requirements
50
+
51
+ - macOS with the **Xcode Command Line Tools** (`xcode-select --install`) — the
52
+ trampoline is compiled with `cc` client-side at `wrap` time.
53
+ - Python ≥ 3.11.
54
+ - A venv with a `pyvenv.cfg` (uv, `python -m venv`, pdm-in-venv mode — all fine).
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ uvx tcc-venv wrap # run without installing
60
+ # or
61
+ pipx install tcc-venv
62
+ # or
63
+ uv tool install tcc-venv
64
+ ```
65
+
66
+ ## Usage
67
+
68
+ Run `wrap` once per venv, then point your launchers / shebangs at `python-tcc`.
69
+
70
+ ### With uv
71
+
72
+ ```bash
73
+ cd my-project
74
+ uv sync # creates ./.venv
75
+ uvx tcc-venv wrap # defaults to ./.venv
76
+ ```
77
+
78
+ Because `uv sync` recreates the interpreter, re-run `wrap` after a sync — it restores
79
+ the same identity from cache, so you do **not** re-grant:
80
+
81
+ ```bash
82
+ uv sync && uvx tcc-venv wrap
83
+ ```
84
+
85
+ ### With pdm
86
+
87
+ pdm can manage an in-project venv. Enable it, install, then wrap:
88
+
89
+ ```bash
90
+ pdm config python.use_venv true
91
+ pdm install # creates ./.venv
92
+ uvx tcc-venv wrap .venv
93
+ ```
94
+
95
+ ### With a bare venv
96
+
97
+ ```bash
98
+ python3 -m venv .venv
99
+ .venv/bin/pip install -e .
100
+ uvx tcc-venv wrap .venv
101
+ ```
102
+
103
+ Tip: `python -m venv --prompt myapp .venv` sets the friendly name used in the
104
+ identity and the TCC dialog (otherwise the venv's parent directory name is used).
105
+
106
+ ## Grant the permission (once)
107
+
108
+ After `wrap`, the binary path is printed. Add **that** binary to the relevant pane:
109
+
110
+ > System Settings → Privacy & Security → Full Disk Access → **+** → select
111
+ > `<venv>/bin/python-tcc-<project>`
112
+
113
+ Automation / Calendar / Reminders prompts appear on first use. From then on, run your
114
+ app through the shim:
115
+
116
+ ```bash
117
+ .venv/bin/python-tcc my_app.py
118
+ # or in a shebang: #!/path/to/.venv/bin/python-tcc
119
+ ```
120
+
121
+ ## Commands
122
+
123
+ | Command | What it does |
124
+ | --- | --- |
125
+ | `tcc-venv wrap [venv ...]` | Install/refresh the launcher (idempotent). Defaults to `./.venv`. Accepts multiple venvs. |
126
+ | `tcc-venv wrap --rebuild` | Force a fresh build + sign (new cdhash — you'll need to re-grant FDA). |
127
+ | `tcc-venv wrap --identifier-prefix PREFIX` | Override the codesign identifier prefix (default `local.tcc-venv`). |
128
+ | `tcc-venv status [venv]` | Show the installed shim, target, cdhash, and expected identifier. |
129
+
130
+ The identifier is `<prefix>.<project>.<hash8>`, where `<prefix>` defaults to
131
+ `local.tcc-venv` (override with `--identifier-prefix` or `$TCC_VENV_IDENTIFIER_PREFIX`)
132
+ and `<hash8>` is derived from the venv's real path so two projects with the same name
133
+ never share a grant.
134
+
135
+ ## How it works (short version)
136
+
137
+ 1. Compile `trampoline.c` once per architecture (cached, unsigned).
138
+ 2. Copy it to `<venv>/bin/python-tcc-<project>` and ad-hoc codesign it with a stable
139
+ `--identifier`.
140
+ 3. Cache the **signed bytes** by identifier; on re-wrap, copy them back so the cdhash
141
+ is byte-identical regardless of codesign version drift.
142
+ 4. Symlink `python-tcc -> python-tcc-<project>`.
143
+
144
+ At runtime the trampoline resolves its own path → venv, refuses to run if a stray
145
+ `$VIRTUAL_ENV` disagrees, `posix_spawn`s the venv's `python`, forwards signals, and
146
+ returns the child's exit status (`128 + signo` on signal death). On non-macOS it's a
147
+ plain `exec` of the venv python with no signing.
148
+
149
+ See [`AGENTS.md`](AGENTS.md) for the full design and invariants.
150
+
151
+ ## License
152
+
153
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "tcc-venv"
3
+ version = "0.1.0"
4
+ description = "Install a stable, codesigned macOS TCC launcher (python-tcc) into a uv/venv"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Moritz Möller", email = "mm@mxs.de" }]
10
+ keywords = ["macos", "tcc", "codesign", "uv", "venv", "full-disk-access"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Environment :: MacOS X",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: MacOS :: MacOS X",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Software Development :: Build Tools",
18
+ ]
19
+ dependencies = []
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/mo22/tcc-venv"
23
+ Repository = "https://github.com/mo22/tcc-venv"
24
+ Issues = "https://github.com/mo22/tcc-venv/issues"
25
+
26
+ [project.scripts]
27
+ tcc-venv = "tcc_venv.cli:main"
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/tcc_venv"]
35
+
36
+ [tool.hatch.build.targets.wheel.force-include]
37
+ "src/tcc_venv/trampoline.c" = "tcc_venv/trampoline.c"
@@ -0,0 +1 @@
1
+ """tcc-venv — stable codesigned macOS TCC launchers for uv/venv Python apps."""
@@ -0,0 +1,321 @@
1
+ """tcc-venv — install a stable, codesigned TCC launcher into a uv/venv.
2
+
3
+ See AGENTS.md for the design. In short: on macOS each wrapped venv gets
4
+
5
+ <venv>/bin/python-tcc-<project> # signed trampoline (stable per-project identity)
6
+ <venv>/bin/python-tcc -> python-tcc-<project> # uniform shim used in shebangs/control
7
+
8
+ so TCC attributes Full Disk Access / Automation to `python-tcc-<project>` instead of
9
+ the moving Homebrew-uv / version-pinned-python paths. On non-macOS, `python-tcc` is a
10
+ plain symlink to `python` (no TCC, pure passthrough).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import hashlib
17
+ import os
18
+ import platform
19
+ import re
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ from pathlib import Path
24
+ from typing import NoReturn
25
+
26
+ IS_MACOS = sys.platform == "darwin"
27
+ CACHE_DIR = Path(os.environ.get("TCC_VENV_CACHE", Path.home() / ".cache" / "tcc-venv"))
28
+ SOURCE = Path(__file__).with_name("trampoline.c")
29
+
30
+ # Generic, non-personal default. The reverse-DNS-ish form is only a codesign
31
+ # identifier string; override per-org with --identifier-prefix / the env var.
32
+ DEFAULT_IDENTIFIER_PREFIX = "local.tcc-venv"
33
+ CFLAGS = ["-Wall", "-Wextra", "-O2"]
34
+
35
+
36
+ # --------------------------------------------------------------------------- #
37
+ # helpers
38
+ # --------------------------------------------------------------------------- #
39
+
40
+
41
+ def _die(msg: str, code: int = 1) -> NoReturn:
42
+ print(f"tcc-venv: {msg}", file=sys.stderr)
43
+ raise SystemExit(code)
44
+
45
+
46
+ def _tool(name: str) -> str:
47
+ """Resolve a build tool, preferring the system copy in /usr/bin over $PATH
48
+ so a shadowed `codesign`/`cc` can't slip into a security-sensitive launcher."""
49
+ system = Path("/usr/bin") / name
50
+ if system.exists():
51
+ return str(system)
52
+ found = shutil.which(name)
53
+ if found:
54
+ return found
55
+ _die(f"required tool not found: {name}")
56
+
57
+
58
+ def _cc() -> str:
59
+ """C compiler. Prefer $CC, then the absolute /usr/bin/cc — the system driver
60
+ shim that auto-injects the active SDK sysroot (the bare `xcrun --find cc`
61
+ toolchain clang does not, so it can't find <errno.h>). Fall back to $PATH."""
62
+ env_cc = os.environ.get("CC")
63
+ if env_cc:
64
+ return env_cc
65
+ if Path("/usr/bin/cc").exists():
66
+ return "/usr/bin/cc"
67
+ found = shutil.which("cc")
68
+ if not found:
69
+ _die("no C compiler (cc) found — install Xcode command line tools.")
70
+ return found
71
+
72
+
73
+ def _venv_dir(arg: str | None) -> Path:
74
+ """Resolve the target venv. Defaults to ./.venv, accepts a venv or its bin/."""
75
+ candidate = Path(arg).expanduser() if arg else Path.cwd() / ".venv"
76
+ candidate = candidate.resolve()
77
+ if candidate.name == "bin":
78
+ candidate = candidate.parent
79
+ if not (candidate / "pyvenv.cfg").exists():
80
+ _die(f"{candidate} is not a venv (no pyvenv.cfg). Run `uv sync` first.")
81
+ return candidate
82
+
83
+
84
+ def _project_name(venv: Path) -> str:
85
+ """Friendly project name: pyvenv.cfg `prompt`, else the venv's parent dir name."""
86
+ name = ""
87
+ cfg = venv / "pyvenv.cfg"
88
+ for line in cfg.read_text().splitlines():
89
+ if "=" in line:
90
+ key, _, value = line.partition("=")
91
+ if key.strip() == "prompt":
92
+ name = value.strip().strip("'\"")
93
+ break
94
+ if not name:
95
+ name = venv.parent.name
96
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-._") or "venv"
97
+ return slug
98
+
99
+
100
+ def _identifier_prefix(args: argparse.Namespace) -> str:
101
+ return (
102
+ getattr(args, "identifier_prefix", None)
103
+ or os.environ.get("TCC_VENV_IDENTIFIER_PREFIX")
104
+ or DEFAULT_IDENTIFIER_PREFIX
105
+ )
106
+
107
+
108
+ def _identity(venv: Path, prefix: str) -> tuple[str, str]:
109
+ """(filename, identifier). Identifier includes a hash of the venv realpath so
110
+ two projects with the same friendly name never share a TCC grant (Codex #6)."""
111
+ name = _project_name(venv)
112
+ digest = hashlib.sha256(str(venv).encode()).hexdigest()[:8]
113
+ return f"python-tcc-{name}", f"{prefix}.{name}.{digest}"
114
+
115
+
116
+ def _arch() -> str:
117
+ return platform.machine() or "unknown"
118
+
119
+
120
+ def _build_unsigned() -> Path:
121
+ """Compile the trampoline; cache the unsigned binary keyed by the source
122
+ content + compiler flags (Codex #3 — mtime is unreliable across reinstalls)."""
123
+ key = hashlib.sha256(
124
+ SOURCE.read_bytes() + b"\0" + " ".join(CFLAGS).encode()
125
+ ).hexdigest()[:16]
126
+ out = CACHE_DIR / "unsigned" / _arch() / f"{key}.bin"
127
+ if out.exists():
128
+ return out
129
+ out.parent.mkdir(parents=True, exist_ok=True)
130
+ tmp = out.with_suffix(".tmp")
131
+ subprocess.run([_cc(), *CFLAGS, str(SOURCE), "-o", str(tmp)], check=True)
132
+ os.replace(tmp, out)
133
+ return out
134
+
135
+
136
+ def _codesign_show(path: Path) -> dict[str, str]:
137
+ res = subprocess.run(
138
+ [_tool("codesign"), "-dvvv", str(path)], capture_output=True, text=True
139
+ )
140
+ info: dict[str, str] = {}
141
+ for line in (res.stderr + res.stdout).splitlines():
142
+ if "=" in line:
143
+ key, _, value = line.partition("=")
144
+ info.setdefault(key.strip(), value.strip())
145
+ return info
146
+
147
+
148
+ def _cdhash(path: Path) -> str:
149
+ return _codesign_show(path).get("CDHash", "?")
150
+
151
+
152
+ def _verify(path: Path, identifier: str) -> bool:
153
+ """A signed binary is good only if it passes codesign --verify, carries the
154
+ expected identifier, and has a real cdhash (Codex #5)."""
155
+ res = subprocess.run(
156
+ [_tool("codesign"), "--verify", "--strict", str(path)],
157
+ capture_output=True,
158
+ text=True,
159
+ )
160
+ if res.returncode != 0:
161
+ return False
162
+ info = _codesign_show(path)
163
+ return info.get("Identifier") == identifier and info.get("CDHash", "?") not in (
164
+ "",
165
+ "?",
166
+ )
167
+
168
+
169
+ def _atomic_install(
170
+ installed: Path, source: Path, identifier: str, *, sign: bool
171
+ ) -> bool:
172
+ """Stage `source` into a temp file beside `installed`, optionally codesign it,
173
+ verify it, then atomically swap it in (Codex #4 — never expose an unsigned or
174
+ half-written binary). Returns False if the result fails verification."""
175
+ tmp = installed.with_name(f".{installed.name}.tmp")
176
+ try:
177
+ shutil.copyfile(source, tmp)
178
+ tmp.chmod(0o755)
179
+ if sign:
180
+ # Capture codesign's chatter (e.g. "replacing existing signature",
181
+ # which arm64 always emits — the linker ad-hoc signs at link time);
182
+ # surface it only if signing actually fails.
183
+ res = subprocess.run(
184
+ [
185
+ _tool("codesign"),
186
+ "--force",
187
+ "--sign",
188
+ "-",
189
+ "--identifier",
190
+ identifier,
191
+ str(tmp),
192
+ ],
193
+ capture_output=True,
194
+ text=True,
195
+ )
196
+ if res.returncode != 0:
197
+ _die(f"codesign failed for {installed}:\n{res.stderr.strip()}")
198
+ if not _verify(tmp, identifier):
199
+ return False
200
+ os.replace(tmp, installed)
201
+ return True
202
+ finally:
203
+ tmp.unlink(missing_ok=True)
204
+
205
+
206
+ def _symlink(link: Path, target: str) -> None:
207
+ if link.is_symlink() or link.exists():
208
+ link.unlink()
209
+ link.symlink_to(target)
210
+
211
+
212
+ # --------------------------------------------------------------------------- #
213
+ # commands
214
+ # --------------------------------------------------------------------------- #
215
+
216
+
217
+ def cmd_wrap(args: argparse.Namespace) -> None:
218
+ prefix = _identifier_prefix(args)
219
+ for raw in args.venv or [None]:
220
+ venv = _venv_dir(raw)
221
+ bindir = venv / "bin"
222
+ shim = bindir / "python-tcc"
223
+
224
+ if not IS_MACOS:
225
+ _symlink(shim, "python")
226
+ print(f"{shim} -> python (non-macOS passthrough)")
227
+ continue
228
+
229
+ filename, identifier = _identity(venv, prefix)
230
+ installed = bindir / filename
231
+ signed_cache = CACHE_DIR / "signed" / identifier
232
+
233
+ # Reuse the exact signed bytes if we have them (Codex #7: copy-back beats
234
+ # re-signing, so the cdhash — and thus the TCC grant — is identical by
235
+ # construction across uv sync / codesign version drift). The restore is
236
+ # verified, not trusted, so a corrupt/mismatched cache falls through to a
237
+ # fresh build (Codex #5).
238
+ restored = False
239
+ if signed_cache.exists() and not args.rebuild:
240
+ restored = _atomic_install(installed, signed_cache, identifier, sign=False)
241
+
242
+ if not restored:
243
+ unsigned = _build_unsigned()
244
+ if not _atomic_install(installed, unsigned, identifier, sign=True):
245
+ _die(f"codesign verification failed for {installed}")
246
+ signed_cache.parent.mkdir(parents=True, exist_ok=True)
247
+ cache_tmp = signed_cache.with_suffix(".tmp")
248
+ shutil.copyfile(installed, cache_tmp)
249
+ os.replace(cache_tmp, signed_cache)
250
+
251
+ _symlink(shim, filename)
252
+
253
+ print(f"wrapped {venv}")
254
+ print(f" binary: {installed}")
255
+ print(f" shim: {shim} -> {filename}")
256
+ print(f" identifier: {identifier}")
257
+ print(f" cdhash: {_cdhash(installed)}")
258
+ if IS_MACOS:
259
+ print(
260
+ "\nGrant the binary Full Disk Access ONCE:\n"
261
+ " System Settings -> Privacy & Security -> Full Disk Access -> add the\n"
262
+ " python-tcc-<project> binary above. Automation/EventKit prompts appear\n"
263
+ " on first use. Re-running `tcc-venv wrap` after `uv sync` restores the\n"
264
+ " identical identity, so the grant persists."
265
+ )
266
+
267
+
268
+ def cmd_status(args: argparse.Namespace) -> None:
269
+ venv = _venv_dir(args.venv)
270
+ shim = venv / "bin" / "python-tcc"
271
+ if not shim.is_symlink():
272
+ print(f"{venv}: not wrapped")
273
+ return
274
+ target = os.readlink(shim)
275
+ print(f"{venv}")
276
+ print(f" shim: {shim} -> {target}")
277
+ real = venv / "bin" / target
278
+ if IS_MACOS and real.exists():
279
+ _, identifier = _identity(venv, _identifier_prefix(args))
280
+ print(f" binary: {real}")
281
+ print(f" cdhash: {_cdhash(real)}")
282
+ print(f" expect: {identifier}")
283
+
284
+
285
+ def _add_prefix_arg(parser: argparse.ArgumentParser) -> None:
286
+ parser.add_argument(
287
+ "--identifier-prefix",
288
+ default=None,
289
+ help="codesign identifier prefix (default: $TCC_VENV_IDENTIFIER_PREFIX or "
290
+ f"{DEFAULT_IDENTIFIER_PREFIX!r}); identifier is <prefix>.<project>.<hash8>",
291
+ )
292
+
293
+
294
+ def main() -> None:
295
+ parser = argparse.ArgumentParser(
296
+ prog="tcc-venv",
297
+ description="Install a stable codesigned TCC launcher into a uv/venv.",
298
+ )
299
+ sub = parser.add_subparsers(dest="cmd", required=True)
300
+
301
+ p_wrap = sub.add_parser("wrap", help="install python-tcc into a venv (idempotent)")
302
+ p_wrap.add_argument("venv", nargs="*", help="venv dir(s); default ./.venv")
303
+ p_wrap.add_argument(
304
+ "--rebuild",
305
+ action="store_true",
306
+ help="force a fresh build+sign (new cdhash — needs re-granting FDA)",
307
+ )
308
+ _add_prefix_arg(p_wrap)
309
+ p_wrap.set_defaults(func=cmd_wrap)
310
+
311
+ p_status = sub.add_parser("status", help="show the wrapper installed in a venv")
312
+ p_status.add_argument("venv", nargs="?", help="venv dir; default ./.venv")
313
+ _add_prefix_arg(p_status)
314
+ p_status.set_defaults(func=cmd_status)
315
+
316
+ args = parser.parse_args()
317
+ args.func(args)
318
+
319
+
320
+ if __name__ == "__main__":
321
+ main()
@@ -0,0 +1,296 @@
1
+ /*
2
+ * tcc-venv trampoline — a stable, codesigned launcher for a uv/venv Python app
3
+ * so macOS TCC (Full Disk Access / Automation) attributes grants to THIS binary
4
+ * instead of the moving Homebrew-uv / version-pinned-python paths.
5
+ *
6
+ * Installed by `tcc-venv` as <venv>/bin/python-tcc-<project> (macOS). It:
7
+ * - self-locates its venv from its own executable path (NOT $VIRTUAL_ENV, which
8
+ * could leak from another project and run the wrong interpreter under this
9
+ * binary's identity — it is only honored if it AGREES with the self-located venv),
10
+ * - posix_spawns <venv>/bin/python with argv[1:] appended (stays the live parent
11
+ * so the child inherits this binary's TCC grant — must NOT exec into python),
12
+ * - runs the child in its own process group and (when interactive) hands it the
13
+ * controlling terminal, so terminal signals reach the child once — not the
14
+ * wrapper and then the child again,
15
+ * - forwards directed signals to the child's process group and propagates exit.
16
+ *
17
+ * The bytes are project-INDEPENDENT (the per-project identity comes from codesign
18
+ * --identifier + the on-disk filename, applied by the installer), so one cached
19
+ * build is copied to every venv.
20
+ *
21
+ * On non-macOS this file is unused — the installer symlinks python-tcc -> python
22
+ * directly — but the code still compiles to a plain exec of the venv python.
23
+ */
24
+ #include <errno.h>
25
+ #include <signal.h>
26
+ #include <spawn.h>
27
+ #include <stdint.h>
28
+ #include <stdio.h>
29
+ #include <stdlib.h>
30
+ #include <string.h>
31
+ #include <sys/wait.h>
32
+ #include <unistd.h>
33
+
34
+ #ifdef __APPLE__
35
+ #include <mach-o/dyld.h>
36
+ #endif
37
+
38
+ extern char **environ;
39
+
40
+ static const int FORWARDED_SIGNALS[] = {
41
+ SIGINT, SIGTERM, SIGHUP, SIGQUIT, SIGUSR1, SIGUSR2,
42
+ };
43
+ #define N_FORWARDED ((int)(sizeof(FORWARDED_SIGNALS) / sizeof(FORWARDED_SIGNALS[0])))
44
+
45
+ static volatile sig_atomic_t child_pid = -1;
46
+
47
+ static void forward_signal(int signo) {
48
+ pid_t pid = (pid_t)child_pid;
49
+ if (pid > 0) {
50
+ /* Child leads its own process group; signal the whole group so any
51
+ * grandchildren are torn down too. */
52
+ kill(-pid, signo);
53
+ }
54
+ }
55
+
56
+ static void install_signal_handlers(void) {
57
+ struct sigaction action;
58
+ memset(&action, 0, sizeof(action));
59
+ action.sa_handler = forward_signal;
60
+ sigemptyset(&action.sa_mask);
61
+ action.sa_flags = SA_RESTART;
62
+ for (int i = 0; i < N_FORWARDED; i++) {
63
+ sigaction(FORWARDED_SIGNALS[i], &action, NULL);
64
+ }
65
+ }
66
+
67
+ /* malloc'd absolute path to this executable, symlinks resolved, or NULL. */
68
+ static char *self_path(void) {
69
+ char *raw = NULL;
70
+ #ifdef __APPLE__
71
+ uint32_t size = 0;
72
+ _NSGetExecutablePath(NULL, &size); /* sets size to the required length */
73
+ raw = malloc(size ? size : 1);
74
+ if (raw == NULL) {
75
+ return NULL;
76
+ }
77
+ if (_NSGetExecutablePath(raw, &size) != 0) {
78
+ free(raw);
79
+ return NULL;
80
+ }
81
+ #else
82
+ size_t cap = 1024;
83
+ for (;;) {
84
+ char *buf = malloc(cap);
85
+ if (buf == NULL) {
86
+ return NULL;
87
+ }
88
+ ssize_t n = readlink("/proc/self/exe", buf, cap);
89
+ if (n < 0) {
90
+ free(buf);
91
+ return NULL;
92
+ }
93
+ if ((size_t)n < cap) {
94
+ buf[n] = '\0';
95
+ raw = buf;
96
+ break;
97
+ }
98
+ free(buf);
99
+ cap *= 2;
100
+ }
101
+ #endif
102
+ char *resolved = realpath(raw, NULL);
103
+ if (resolved != NULL) {
104
+ free(raw);
105
+ return resolved;
106
+ }
107
+ return raw; /* fall back to the unresolved path if realpath fails */
108
+ }
109
+
110
+ /* Strip the last path component in place (dirname). */
111
+ static int strip_component(char *path) {
112
+ char *slash = strrchr(path, '/');
113
+ if (slash == NULL || slash == path) {
114
+ return -1;
115
+ }
116
+ *slash = '\0';
117
+ return 0;
118
+ }
119
+
120
+ /* malloc'd "dir/leaf", or NULL. */
121
+ static char *path_join(const char *dir, const char *leaf) {
122
+ size_t n = strlen(dir) + 1 + strlen(leaf) + 1;
123
+ char *out = malloc(n);
124
+ if (out == NULL) {
125
+ return NULL;
126
+ }
127
+ snprintf(out, n, "%s/%s", dir, leaf);
128
+ return out;
129
+ }
130
+
131
+ int main(int argc, char *argv[]) {
132
+ /* self = <venv>/bin/python-tcc-<project> -> venv = dirname(dirname(self)) */
133
+ char *venv = self_path();
134
+ if (venv == NULL) {
135
+ fprintf(stderr, "python-tcc: cannot resolve own executable path\n");
136
+ return 127;
137
+ }
138
+ if (strip_component(venv) != 0 || strip_component(venv) != 0) {
139
+ fprintf(stderr, "python-tcc: unexpected install location (not <venv>/bin/...)\n");
140
+ free(venv);
141
+ return 127;
142
+ }
143
+
144
+ /* Security: never let a stray $VIRTUAL_ENV redirect us to another venv. */
145
+ const char *venv_env = getenv("VIRTUAL_ENV");
146
+ if (venv_env != NULL && venv_env[0] != '\0') {
147
+ char *env_resolved = realpath(venv_env, NULL);
148
+ if (env_resolved != NULL) {
149
+ int mismatch = strcmp(env_resolved, venv) != 0;
150
+ if (mismatch) {
151
+ fprintf(
152
+ stderr,
153
+ "python-tcc: refusing to run — $VIRTUAL_ENV (%s) disagrees with my venv (%s)\n",
154
+ env_resolved, venv);
155
+ }
156
+ free(env_resolved);
157
+ if (mismatch) {
158
+ free(venv);
159
+ return 125;
160
+ }
161
+ }
162
+ }
163
+
164
+ char *python = path_join(venv, "bin/python");
165
+ if (python == NULL) {
166
+ fprintf(stderr, "python-tcc: out of memory\n");
167
+ free(venv);
168
+ return 127;
169
+ }
170
+ if (access(python, X_OK) != 0) {
171
+ /* Fall back to python3 if a bare `python` is absent. */
172
+ char *python3 = path_join(venv, "bin/python3");
173
+ if (python3 != NULL && access(python3, X_OK) == 0) {
174
+ free(python);
175
+ python = python3;
176
+ } else {
177
+ fprintf(stderr, "python-tcc: no venv python at %s\n", python);
178
+ free(python3);
179
+ free(python);
180
+ free(venv);
181
+ return 127;
182
+ }
183
+ }
184
+ free(venv);
185
+
186
+ /* child argv: python + argv[1:] (argc may be 0 in a pathological exec). */
187
+ int passthrough = argc > 1 ? argc - 1 : 0;
188
+ char **child_argv = calloc((size_t)passthrough + 2, sizeof(char *));
189
+ if (child_argv == NULL) {
190
+ fprintf(stderr, "python-tcc: out of memory\n");
191
+ free(python);
192
+ return 127;
193
+ }
194
+ child_argv[0] = python;
195
+ for (int i = 0; i < passthrough; i++) {
196
+ child_argv[i + 1] = argv[i + 1];
197
+ }
198
+ child_argv[passthrough + 1] = NULL;
199
+
200
+ #ifndef __APPLE__
201
+ /* No TCC off-mac: just become python (cheaper, no supervisor needed). */
202
+ execv(python, child_argv);
203
+ fprintf(stderr, "python-tcc: execv %s failed: %s\n", python, strerror(errno));
204
+ free(child_argv);
205
+ free(python);
206
+ return 127;
207
+ #else
208
+ /* Don't let the wrapper be stopped if it writes to the tty while the child
209
+ * owns the terminal foreground (set below). */
210
+ signal(SIGTTOU, SIG_IGN);
211
+
212
+ /* Block the forwarded signals across spawn + child_pid assignment so a
213
+ * signal arriving before the handlers are live can't kill only the wrapper
214
+ * and orphan python (the handlers are a no-op until child_pid is valid). */
215
+ sigset_t block, prev;
216
+ sigemptyset(&block);
217
+ for (int i = 0; i < N_FORWARDED; i++) {
218
+ sigaddset(&block, FORWARDED_SIGNALS[i]);
219
+ }
220
+ sigprocmask(SIG_BLOCK, &block, &prev);
221
+
222
+ /* Run the child in its own process group so terminal-generated signals are
223
+ * delivered to it once, not to the wrapper and then forwarded again. Reset
224
+ * its signal mask to the pre-block set (SETSIGMASK) and its dispositions to
225
+ * default (SETSIGDEF) — otherwise the child inherits our temporarily-blocked
226
+ * mask and never sees a forwarded SIGTERM. */
227
+ sigset_t child_defaults;
228
+ sigemptyset(&child_defaults);
229
+ for (int i = 0; i < N_FORWARDED; i++) {
230
+ sigaddset(&child_defaults, FORWARDED_SIGNALS[i]);
231
+ }
232
+ sigaddset(&child_defaults, SIGTTOU);
233
+
234
+ posix_spawnattr_t attr;
235
+ posix_spawnattr_init(&attr);
236
+ posix_spawnattr_setflags(
237
+ &attr, POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_SETSIGDEF);
238
+ posix_spawnattr_setpgroup(&attr, 0); /* child becomes its own group leader */
239
+ posix_spawnattr_setsigmask(&attr, &prev);
240
+ posix_spawnattr_setsigdefault(&attr, &child_defaults);
241
+
242
+ pid_t pid;
243
+ int spawn_status = posix_spawn(&pid, python, NULL, &attr, child_argv, environ);
244
+ posix_spawnattr_destroy(&attr);
245
+ free(child_argv);
246
+ if (spawn_status != 0) {
247
+ sigprocmask(SIG_SETMASK, &prev, NULL);
248
+ fprintf(stderr, "python-tcc: failed to spawn %s: %s\n", python, strerror(spawn_status));
249
+ free(python);
250
+ return 127;
251
+ }
252
+
253
+ child_pid = (sig_atomic_t)pid;
254
+
255
+ /* Hand the controlling terminal's foreground to the child's group so an
256
+ * interactive REPL / input() keeps working and Ctrl-C reaches the child. */
257
+ int interactive = isatty(STDIN_FILENO);
258
+ if (interactive) {
259
+ tcsetpgrp(STDIN_FILENO, pid); /* pid == the child's pgid */
260
+ }
261
+
262
+ install_signal_handlers();
263
+ sigprocmask(SIG_SETMASK, &prev, NULL);
264
+
265
+ int status;
266
+ for (;;) {
267
+ pid_t waited = waitpid(pid, &status, 0);
268
+ if (waited == pid) {
269
+ break;
270
+ }
271
+ if (waited == -1 && errno == EINTR) {
272
+ continue;
273
+ }
274
+ if (waited == -1) {
275
+ fprintf(stderr, "python-tcc: waitpid failed: %s\n", strerror(errno));
276
+ free(python);
277
+ return 1;
278
+ }
279
+ }
280
+ child_pid = -1;
281
+ free(python);
282
+
283
+ /* Reclaim the terminal foreground for our own group before exiting. */
284
+ if (interactive) {
285
+ tcsetpgrp(STDIN_FILENO, getpgrp());
286
+ }
287
+
288
+ if (WIFEXITED(status)) {
289
+ return WEXITSTATUS(status);
290
+ }
291
+ if (WIFSIGNALED(status)) {
292
+ return 128 + WTERMSIG(status);
293
+ }
294
+ return 1;
295
+ #endif
296
+ }
File without changes
@@ -0,0 +1,79 @@
1
+ # Task: make tcc-venv release-ready (GitHub + PyPI)
2
+
3
+ Status: **open** — created 2026-06-05. Tool is built and working locally on alphagit-track;
4
+ this task tracks the work to publish it publicly.
5
+
6
+ ## Goal
7
+ Publish `tcc-venv` as a small open-source tool (GitHub repo + PyPI package) that gives
8
+ uv/venv Python apps a stable, codesigned macOS TCC identity so Full Disk Access /
9
+ Automation grants survive uv/python upgrades and show a per-project name in the
10
+ permission dialog.
11
+
12
+ ## Current state (what already exists)
13
+ - Package at `~/workspace/tcc-venv` (`tcc-venv` CLI, `tcc_venv` package).
14
+ - `tcc-venv wrap <venv>` installs `<venv>/bin/python-tcc-<project>` (signed trampoline) +
15
+ `<venv>/bin/python-tcc` symlink (→ that on macOS, → `python` on non-macOS).
16
+ - Verified locally: correct venv prefix via the shim, deterministic cdhash across
17
+ re-wrap, `$VIRTUAL_ENV` mismatch refused, signal forwarding + exit-code propagation,
18
+ wipe-then-rewrap restores identical identity (uv-sync-proof).
19
+ - Codex review fixes already applied: self-locate only (no `$VIRTUAL_ENV` preference),
20
+ collision-resistant identifier `ai.mxs.tcc.<project>.<hash8-of-venv-realpath>`,
21
+ signed-bytes cache copy-back, absolute invocation (no relative shebang for control).
22
+
23
+ ## Blocking before any public release (verify, don't assume)
24
+ 1. **Verify Automation/EventKit inheritance on real macOS.** We only proved Full Disk
25
+ Access. Trigger a Reminders/Calendar/Mail (EventKit + AppleEvents) access through a
26
+ launchd-launched `python-tcc-<project>` and confirm the grant attaches to it (not to
27
+ the python child / osascript). Codex flagged these may key on the sending process.
28
+ 2. **Verify the TCC dialog/Settings display name** actually shows `python-tcc-<project>`
29
+ (not `python-tcc`, `python`, or the resolved path) for a bare ad-hoc CLI binary
30
+ invoked via the symlink. If it disappoints, fall back to a minimal signed `.app`
31
+ with `CFBundleName`/`CFBundleIdentifier`.
32
+ 3. **Document the foundations honestly.** It relies on *undocumented* TCC
33
+ responsible-process inheritance and ad-hoc cdhash determinism that can drift across
34
+ macOS/codesign versions. README needs a clear "unofficial, may break on a future
35
+ macOS" caveat + a tested-OS-version matrix.
36
+
37
+ ## Release checklist
38
+ ### a) GitHub
39
+ - [ ] Create public repo `mo22/tcc-venv` (decide org/user). `gh repo create`.
40
+ - [ ] Mirror from alphagit (`git@alpha.mxs.de:mmoeller/tcc-venv.git`) → add GitHub remote.
41
+ - [ ] CI: GitHub Actions on macOS runner — build the trampoline, run the behavioral
42
+ tests (venv prefix, determinism, `$VIRTUAL_ENV` guard, signals, exit codes).
43
+ - [ ] Standard audit stack per global setup (osv-scanner, gitleaks) — light, few deps.
44
+
45
+ ### b) Deploy to PyPI
46
+ - [x] Confirm the `tcc-venv` name is free on PyPI (it is, 2026-06-05). Only `tcc-venv`
47
+ is published; `python-tcc` is the installed binary/shim name, never a package.
48
+ License: MIT.
49
+ - [ ] Trusted-publishing (PyPI OIDC) via GitHub Actions on tag, or manual `uv build` +
50
+ `twine`/`uv publish`.
51
+ - [ ] `pipx install tcc-venv` / `uvx tcc-venv` smoke test from the published artifact.
52
+ - [ ] Note: wheel ships `trampoline.c`; build happens client-side at `wrap` time (needs
53
+ `cc`). Document the Xcode CLT requirement.
54
+
55
+ ### c) Parameterize the branding
56
+ - [x] Make the identifier prefix configurable (flag / env / config) instead of the
57
+ hardcoded `ai.mxs.tcc.`. Done 2026-06-05: `--identifier-prefix` flag +
58
+ `$TCC_VENV_IDENTIFIER_PREFIX`, default `local.tcc-venv`.
59
+ - [ ] Make the shim/binary name prefix (`python-tcc`) overridable if needed.
60
+ - [x] Strip any other mxs-specific assumptions (identifier prefix was the only one).
61
+
62
+ ### d) Docs & framing
63
+ - [ ] README: the problem (uv/python upgrade kills TCC grants), the mechanism, install,
64
+ `wrap`, the uv-sync re-wrap story, the OS-version caveats.
65
+ - [ ] **Security framing:** explicit that it does NOT bypass TCC — the user still grants
66
+ explicitly in System Settings; it only stabilizes the identity. Avoid reading as a
67
+ TCC-evasion aid.
68
+ - [ ] Comparison / prior art note (codesign-for-TCC blog posts, why a venv-level tool).
69
+
70
+ ### e) Nice-to-have
71
+ - [ ] `tcc-venv unwrap` (restore plain `python-tcc` → none / clean up).
72
+ - [ ] `tcc-venv doctor` — check FDA state where detectable, surface stale wraps after uv sync.
73
+ - [ ] Optional `.app` bundle mode for predictable dialog naming.
74
+ - [ ] A `uv sync` convenience wrapper or git hook that re-runs `wrap`.
75
+
76
+ ## Validation strategy
77
+ Use the personal fleet as the real-world shakeout before publishing: mac-mcp,
78
+ fileindex-mcp, webshell (agent-mcp). Keep it on alphagit until items 1–3 above are
79
+ confirmed on the macOS versions in use.