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.
- tcc_venv-0.1.0/.github/workflows/publish.yml +38 -0
- tcc_venv-0.1.0/.gitignore +19 -0
- tcc_venv-0.1.0/AGENTS.md +89 -0
- tcc_venv-0.1.0/CLAUDE.md +1 -0
- tcc_venv-0.1.0/LICENSE +21 -0
- tcc_venv-0.1.0/PKG-INFO +173 -0
- tcc_venv-0.1.0/README.md +153 -0
- tcc_venv-0.1.0/pyproject.toml +37 -0
- tcc_venv-0.1.0/src/tcc_venv/__init__.py +1 -0
- tcc_venv-0.1.0/src/tcc_venv/cli.py +321 -0
- tcc_venv-0.1.0/src/tcc_venv/trampoline.c +296 -0
- tcc_venv-0.1.0/tasks/.gitkeep +0 -0
- tcc_venv-0.1.0/tasks/release-public.md +79 -0
|
@@ -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
|
tcc_venv-0.1.0/AGENTS.md
ADDED
|
@@ -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`.
|
tcc_venv-0.1.0/CLAUDE.md
ADDED
|
@@ -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.
|
tcc_venv-0.1.0/PKG-INFO
ADDED
|
@@ -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).
|
tcc_venv-0.1.0/README.md
ADDED
|
@@ -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.
|