Files
reyna-cli/docs/plans/2026-08-15-stable-signed-python-permission-runner.md
2026-08-16 08:24:45 -04:00

447 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Simple Signed Python Launcher — Future Hardening Plan
> **Selected implementation (2026-08-15):** The user chose the VoiceAgent-style design now: the manually Xcode-signed `Reyna CLI.app` accepts `--python`, reads `REYNA_CLI_DIR` / `REYNA_CLI_PYTHON` from environment or `~/.reyna-cli.env` / `~/.config/reyna-cli/env`, and runs `python -m reyna_cli.cli` with forwarded arguments. Mutable Python changes therefore take effect without another native rebuild or signing pass. The launcher removes inherited Python interpreter overrides (`PYTHONPATH`, `PYTHONHOME`, `VIRTUAL_ENV`) so Hermes cannot contaminate the selected project venv.
>
> **Deferred hardening:** The allowlist, owner-safe runtime validation, bounded JSON protocol, and native operation routing described below are deliberately retained as the future hardening design. They are not part of the current simple launcher and must not be represented as its present security boundary.
> **For Hermes:** Use `subagent-driven-development` skill to implement this plan task-by-task.
**Goal:** Let the stable, Apple-signed `Reyna CLI.app` launch a narrowly allowlisted set of changeable Reyna CLI Python operations, so approved macOS privacy access remains attributable to `com.reyna.cli.privacy-host` while Python logic can be updated without rebuilding or re-signing the app.
**Architecture:** Reuse the existing signed `Reyna CLI.app` / `ReynaCLIHost` as the only privacy identity; do **not** create a second app or turn the AF_UNIX host into a LAN service. Add an exact-operation Swift process runner to the app and a Python adapter that requests only named operations through the app. The Swift layer accepts neither an arbitrary executable nor arbitrary script/module/arguments; it validates an owner-safe runtime configuration, launches a fixed Python module with a bounded JSON request on stdin, and returns one bounded JSON response. Existing native Calendar, Contacts, and Reminders operations stay in Swift and retain their current owner-only socket boundary.
**Tech Stack:** Swift 6 / Xcode signed macOS app, Foundation `Process`, Python 3.14 / Typer / pytest / uv, JSON-lines protocol, existing Xcode Automatic Signing.
---
## Evidence and design decisions
### What VoiceAgent proved
- `/Users/adolforeyna/VoiceAgent/swift/VoiceAgentLauncher.swift:35-54` chooses a workspace and Python interpreter from user configuration, then launches `app_main.py` through Foundation `Process` (`:67-80`).
- `/Users/adolforeyna/VoiceAgent/build_app.sh:57-83` deliberately skips rebuilding/re-signing when native Swift sources are unchanged; Python files are copied independently. Its `AGENTS.md` describes the intended result: Python edits take effect without a new permission request.
- The mechanism is relevant, but its implementation is **not** a safe drop-in: it permits project/Python selection from environment and three env files, then runs an arbitrary workspace entry point. That is acceptable for a personal app launcher, but too broad for Reyna CLI’s signed privacy host.
### Reyna CLI baseline
- The existing native identity is already the right foundation: `native/ReynaCLIHost/ReynaCLIHost/Info.plist` declares bundle ID `com.reyna.cli.privacy-host`, and the local bundle verification returned that ID with TeamIdentifier `RHUM5U925W`.
- `src/reyna_cli/privacy_host.py:158-176` launches the signed bundle through a per-user LaunchAgent, while `Sources/ReynaCLIHostCore/AppEntry.swift:7-18` currently provides only owner-only socket or stdin JSON-lines modes.
- Calendar, Contacts, and Reminders are intentionally native Swift providers. `docs/remaining-coverage-matrix.md:5-20` explicitly keeps direct speech/media paths outside the privacy host; this plan changes that only after a real permission-attribution spike proves the intended path.
- Current file/live speech code recompiles source strings to hash-named binaries under `~/Library/Application Support/reyna-cli/speech/` (`src/reyna_cli/speech_execution.py:14-113`, `speech_live.py:17-158`). That defeats a stable executable identity and is the first migration target.
### Non-goals and invariants
- No generic “run Python,” shell, script path, module, arbitrary environment, TCP, or LAN RPC interface.
- Superseded on 2026-08-15: Apple Notes is now a mutable Python-only CLI route using fixed JXA and the existing Automation approval; it does not modify the signed native host.
- No removal/cutover of MacMiniMCP.
- No automatic Privacy & Security click. The one live TCC grant is a user-facing, explicit acceptance step.
- Never rebuild, replace, or re-sign the installed production app merely because Python changed. A native Swift / plist / entitlement change is a separately approved release.
## Threat model / chosen boundary
The mutable Python source is intentionally trusted **as the local user’s Reyna CLI logic**. It can evolve without invalidating the `.app` signature. Therefore the runner must prevent other callers from converting the signed host into a generic privileged code launcher:
1. The native app accepts a constant allowlist of operation names only (initially `speech.transcribe_file` and `speech.live_session`; add later operations deliberately).
2. It selects one fixed Python module for each operation. It never accepts `--script`, `-c`, a module name, an executable path, or shell syntax from the CLI/RPC payload.
3. Runtime configuration contains only canonical absolute paths for the Reyna CLI repo and venv Python; it is owner-owned, non-symlinked, and not group/other writable. Environment input cannot override it.
4. The request is bounded JSON on stdin and the response is bounded JSON on stdout. Arguments are schema-validated in both Swift and Python; stderr is bounded diagnostic text and never leaks credentials.
5. The app only exposes this runner as a foreground/on-demand executable mode, not through the persistent AF_UNIX service. The socket server continues to dispatch Swift-native privacy operations only.
---
### Task 0: Establish a clean, reproducible baseline and canonical checkout
**Objective:** Record the baseline and remove the current test ambiguity before changing behavior.
**Files:**
- Create: `docs/plans/2026-08-15-stable-signed-python-permission-runner.md` (this plan)
- Create: `docs/permission-runner-baseline.md`
- Test: existing Python and Swift suites (no product-code changes)
**Step 1: Identify the canonical repository path**
Run:
```bash
cd /Users/adolforeyna/Projects/reyna-cli
pwd -P
git rev-parse --show-toplevel
git status --short
```
Record the physical path and preserve the existing unrelated working-tree changes. Do not reset, clean, or stage them.
**Step 2: Reproduce the current verification blockers**
Run the Python suite with the project interpreter isolated from Hermes’ environment, then run the Swift suite from the same physical checkout:
```bash
env -i HOME="$HOME" PATH="/opt/homebrew/bin:/usr/bin:/bin" \
/Users/adolforeyna/Projects/reyna-cli/.venv/bin/python -m pytest -q
cd /Users/adolforeyna/Projects/reyna-cli/native/ReynaCLIHost && swift test
```
Expected current issues to document, not hide:
- Python’s `.venv` imports Hermes’ Python 3.11 `pydantic_core` under a Python 3.14 executable.
- Swift has duplicate module-cache paths because both `/Users/adolforeyna/Projects/reyna-cli` and `/Users/adolforeyna/Projects/platform/reyna-cli` are being used.
**Step 3: Repair only the development environment after identifying its owner**
Use the canonical path exclusively; resolve the venv/module-cache issue without editing application logic. Re-run the same commands and record the exact green baseline. If fixing it would alter a shared environment or discard user work, stop and request scope confirmation.
**Step 4: Commit the baseline note only after review**
```bash
git add docs/permission-runner-baseline.md docs/plans/2026-08-15-stable-signed-python-permission-runner.md
git diff --cached --check
git commit -m "docs: record signed Python runner baseline"
```
---
### Task 1: Specify the fixed runner protocol and runtime configuration (TDD)
**Objective:** Define a testable contract that makes generic code execution impossible.
**Files:**
- Create: `src/reyna_cli/permission_runner.py`
- Create: `tests/test_permission_runner.py`
- Create: `native/ReynaCLIHost/Sources/ReynaCLIHostCore/PythonRunnerProtocol.swift`
- Create: `native/ReynaCLIHost/Tests/ReynaCLIHostTests/PythonRunnerProtocolTests.swift`
**Step 1: Write failing Python contract tests**
Cover:
```python
def test_operation_allowlist_has_only_named_operations(): ...
def test_runtime_config_rejects_relative_symlink_or_group_writable_paths(tmp_path): ...
def test_request_rejects_script_module_shell_and_unknown_operation(): ...
def test_payload_is_json_object_and_is_bounded(): ...
def test_config_does_not_read_environment_overrides(monkeypatch): ...
```
**Step 2: Write failing Swift protocol tests**
Cover exact operation matching, max request/response size, invalid JSON, extra command-line flags, and a rejection result that contains no runtime secrets.
**Step 3: Implement the minimal shared contract**
Use a stable versioned schema:
```json
{"version":1,"id":"uuid","operation":"speech.transcribe_file","arguments":{"audio_path":"/absolute/path.wav","locale":"en-US"}}
```
Use a Swift `enum PythonOperation: String, CaseIterable` and a Python `Final[frozenset[str]]`; do not duplicate untested string maps across call sites. Put the fixed module mapping in Swift, for example:
```swift
case .speechTranscribeFile: return "reyna_cli.permission_runner"
```
**Step 4: Define runtime configuration**
Use one fixed per-user file:
`~/Library/Application Support/reyna-cli/privacy/python-runtime.json`
It contains only:
```json
{"version":1,"repo_root":"/absolute/canonical/reyna-cli","python":"/absolute/canonical/reyna-cli/.venv/bin/python"}
```
Validate its path components with the same owner/no-symlink discipline used for the native socket. Reject a missing, non-regular, symlinked, foreign-owned, or group/other-writable config, repo, Python executable, and source package path. The config is not an environment file and has no token fields.
**Step 5: Verify**
```bash
env -u VIRTUAL_ENV uv run pytest tests/test_permission_runner.py -v
cd native/ReynaCLIHost && swift test --filter PythonRunnerProtocolTests
```
**Step 6: Commit**
```bash
git add src/reyna_cli/permission_runner.py tests/test_permission_runner.py \
native/ReynaCLIHost/Sources/ReynaCLIHostCore/PythonRunnerProtocol.swift \
native/ReynaCLIHost/Tests/ReynaCLIHostTests/PythonRunnerProtocolTests.swift
git diff --cached --check
git commit -m "feat: define restricted signed Python runner protocol"
```
---
### Task 2: Add the signed Swift process runner without changing the socket host
**Objective:** Make `ReynaCLIHost` the parent process for one allowlisted Python operation and preserve the existing native socket behavior byte-for-byte.
**Files:**
- Create: `native/ReynaCLIHost/Sources/ReynaCLIHostCore/PythonOperationRunner.swift`
- Create: `native/ReynaCLIHost/Tests/ReynaCLIHostTests/PythonOperationRunnerTests.swift`
- Modify: `native/ReynaCLIHost/Sources/ReynaCLIHostCore/AppEntry.swift`
- Modify: `native/ReynaCLIHost/Package.swift`
- Modify: `native/ReynaCLIHost/ReynaCLIHost.xcodeproj/project.pbxproj`
- Test: `native/ReynaCLIHost/Tests/ReynaCLIHostTests/HostIntegrationTests.swift`
**Step 1: Write failing runner tests**
Use an injected process factory. Assert:
- command is exactly `[configuredPython, "-m", "reyna_cli.permission_runner", "--operation", exactOperation]`;
- working directory is the validated repo root;
- only a minimal fixed environment is passed (`HOME`, `PATH`, `LANG`, `LC_*` as explicitly required), not the parent process environment;
- JSON is written once to stdin; stdout is a single bounded JSON object; nonzero exit, timeout, malformed response, or oversized output becomes a safe structured error;
- `--socket` never dispatches to this runner.
**Step 2: Extend `runReynaCLIHost` with one exact mode**
Add only:
```text
--python-operation <operation>
```
Reject duplicate flags, positional leftovers, `--socket` combinations, unknown operations, and absent input. Keep the existing `--socket <path>` and stdin native JSON-lines behavior unchanged.
**Step 3: Update both build systems**
Add new Swift files to `Package.swift` automatically by target path and add file/build references in the Xcode project. Do not alter bundle identifier, signing configuration, Info.plist privacy keys, or the existing EventKit/Contacts linkage.
**Step 4: Verify**
```bash
cd native/ReynaCLIHost
swift test --filter PythonOperationRunnerTests
swift test --filter HostIntegrationTests
xcodebuild -project ReynaCLIHost.xcodeproj -scheme 'Reyna CLI' \
-configuration Release CODE_SIGNING_ALLOWED=NO build
```
**Step 5: Commit**
```bash
git add native/ReynaCLIHost
git diff --cached --check
git commit -m "feat: add allowlisted Python operation runner to signed host"
```
---
### Task 3: Implement the mutable Python operation adapter (TDD)
**Objective:** Receive the bounded request, validate it again, and execute only the named Reyna CLI operation.
**Files:**
- Modify: `src/reyna_cli/permission_runner.py`
- Modify: `tests/test_permission_runner.py`
- Create: `tests/test_permission_runner_integration.py`
**Step 1: Write failing adapter tests**
Tests must prove that the adapter:
- reads exactly one request object from stdin and returns one JSON object;
- rejects an extra line, unsupported schema version, unknown operation, non-absolute/unreadable input path, missing locale, and unknown keys;
- never invokes `shell=True`, `os.system`, `exec`, or dynamic import based on input;
- strips/truncates child diagnostics and never returns environment values;
- invokes an internal function table keyed by constant operation names.
**Step 2: Implement operation handlers as ordinary Python functions**
Start with only the two speech operations. Each operation receives a typed dataclass, not a raw dict. Return an operation-specific typed response. Keep any long-lived live-transcription child owned by the signed host invocation; do not daemonize it through the privacy socket.
**Step 3: Add an end-to-end test harness**
Use a temporary validated repo/config plus a harmless Python fixture module or injected runner seam. Verify host → Python request/response shape, negative paths, and that the host’s executable is the immediate parent. This is a structural test, not evidence of TCC attribution.
**Step 4: Verify**
```bash
env -u VIRTUAL_ENV uv run pytest tests/test_permission_runner.py tests/test_permission_runner_integration.py -v
cd native/ReynaCLIHost && swift test
```
**Step 5: Commit**
```bash
git add src/reyna_cli/permission_runner.py tests/test_permission_runner.py tests/test_permission_runner_integration.py
git diff --cached --check
git commit -m "feat: add validated mutable Python operation adapter"
```
---
### Task 4: Add the Reyna CLI client route and preserve compatibility (TDD)
**Objective:** Route supported CLI operations through the signed host without making the host a generic subprocess service.
**Files:**
- Create: `src/reyna_cli/signed_runner_client.py`
- Create: `tests/test_signed_runner_client.py`
- Modify: `src/reyna_cli/cli.py`
- Modify: `src/reyna_cli/privacy_host.py`
- Modify: `tests/test_reyna.py`
- Modify: `tests/test_privacy_host.py`
**Step 1: Write failing client tests**
Test the client builds the exact signed-app executable path from `app_bundle_executable_path()`, sends the versioned JSON request over stdin, enforces timeouts/output caps, and fails closed if `validate_app_bundle()` is not signed/valid. Test that no app validation output leaks signing identity data.
**Step 2: Implement `SignedRunnerClient`**
Use direct argument vectors:
```python
[app_executable, "--python-operation", operation]
```
Pass request JSON via `input=...`; never encode it in a shell command, URL, environment variable, or temporary world-readable file.
**Step 3: Wire explicit speech commands behind a feature gate**
Add a `--via-signed-host` internal/experimental option first, defaulting to the old direct code until the live TCC spike passes. The default does not change in this task. This provides an easy rollback and avoids claiming migration before permission identity is verified.
**Step 4: Verify**
```bash
env -u VIRTUAL_ENV uv run pytest tests/test_signed_runner_client.py tests/test_reyna.py tests/test_privacy_host.py -q
env -u VIRTUAL_ENV uv run reyna-cli local-services speech transcribe-file --help
```
**Step 5: Commit**
```bash
git add src/reyna_cli/signed_runner_client.py src/reyna_cli/cli.py src/reyna_cli/privacy_host.py tests/
git diff --cached --check
git commit -m "feat: route opt-in speech calls through signed runner"
```
---
### Task 5: Replace runtime Swift compilation only after the live permission spike passes
**Objective:** Eliminate hash-named runtime binaries for migrated speech operations while keeping mutable Python orchestration.
**Files:**
- Modify: `src/reyna_cli/speech_execution.py`
- Modify: `src/reyna_cli/speech_live.py`
- Modify: `src/reyna_cli/permission_runner.py`
- Modify: `tests/test_media_execution.py`
- Modify: `tests/test_permission_runner.py`
- Modify: `docs/remaining-coverage-matrix.md`
**Step 1: Write red migration tests**
Assert signed-runner paths do not call `swiftc`, do not create hash-named binaries, and return `source: "reyna_cli_signed_host"`. Preserve a direct-path regression test until cutover approval.
**Step 2: Choose the minimal proven execution shape**
After the spike, choose one of these based on real evidence:
- **Preferred:** move the SpeechAnalyzer implementation into a signed Swift operation in `ReynaCLIHost`; Python remains mutable validation/orchestration around it.
- **Only if the spike proves TCC attribution through the Python child:** retain the Python operation runner and invoke the required trusted helper under the signed host, with no runtime compilation.
Do not infer that a child process inherits usable TCC authorization merely because its parent is bundle-associated. The live prompt and post-edit run are the acceptance evidence.
**Step 3: Keep live-session ownership clear**
The client process owns the signed-host child for the duration of `speech.live_session`; closure sends EOF and waits. It must not leave an orphan, long-running process or open listening endpoint.
**Step 4: Verify**
```bash
env -u VIRTUAL_ENV uv run pytest tests/test_media_execution.py tests/test_permission_runner.py -q
cd native/ReynaCLIHost && swift test
```
**Step 5: Commit**
```bash
git add src/reyna_cli/speech_execution.py src/reyna_cli/speech_live.py src/reyna_cli/permission_runner.py tests/test_media_execution.py tests/test_permission_runner.py docs/remaining-coverage-matrix.md
git diff --cached --check
git commit -m "refactor: run speech through stable signed host"
```
---
### Task 6: Build, sign, and run the one-time TCC acceptance test
**Objective:** Prove the real macOS behavior that the design is intended to preserve.
**Files:**
- Modify: `docs/remaining-coverage-matrix.md`
- Create: `docs/permission-runner-verification.md`
**Step 1: Prepare before requesting permission**
Run all automated checks first. Build the app with its existing Xcode Automatic Signing configuration. Validate it using the existing `reyna-cli privacy-host status` / app-bundle validation path and confirm it is not ad-hoc.
**Step 2: Ask the user before the live privileged run**
Explain in one sentence: “Reyna CLI will request Speech Recognition access under its own signed app identity; no audio is uploaded.” Wait for explicit approval before launching the operation that triggers the OS prompt.
**Step 3: Grant once and verify attribution**
After the user grants it, run a known local fixture through `--via-signed-host`. Record only pass/fail and visible app identity, not TCC database contents, secrets, or unrelated privacy records.
**Step 4: Verify the promised update behavior**
1. Change a harmless Python response marker (not Swift, plist, bundle ID, entitlement, or signing setting).
2. Re-run the same signed-host operation.
3. Confirm the changed Python behavior is present and macOS does **not** request permission again.
4. Revert the harmless marker.
This is the key acceptance criterion. If it fails, revert the feature gate to direct mode and document the result; do not repeatedly prompt or rebuild blindly.
**Step 5: Commit evidence/docs**
```bash
git add docs/remaining-coverage-matrix.md docs/permission-runner-verification.md
git diff --cached --check
git commit -m "docs: verify signed Python permission runner"
```
---
### Task 7: Controlled default cutover and release verification
**Objective:** Make signed-host execution the default only after the verified acceptance test and preserve rollback.
**Files:**
- Modify: `src/reyna_cli/cli.py`
- Modify: `src/reyna_cli/signed_runner_client.py`
- Modify: `tests/test_reyna.py`
- Modify: `README.md`
- Modify: `docs/remaining-coverage-matrix.md`
**Step 1: Write red default/rollback tests**
Verify supported speech commands default to the signed route when the signed bundle validates, but `--direct` remains a documented temporary fallback. If the signed bundle is missing, ad-hoc, invalid, or operation unsupported, the command fails plainly; it does not silently use an untrusted generic subprocess route.
**Step 2: Switch the default and document boundary**
Document which operations are: (a) signed privacy-host runner, (b) direct local CLI, and (c) remote service. State that Python logic may change independently, but native host changes require a signed app release and may require a new permission review.
**Step 3: Full verification**
```bash
env -u VIRTUAL_ENV uv run pytest -q
cd native/ReynaCLIHost && swift test
cd /Users/adolforeyna/Projects/reyna-cli
xcodebuild -project native/ReynaCLIHost/ReynaCLIHost.xcodeproj -scheme 'Reyna CLI' \
-configuration Release CODE_SIGNING_ALLOWED=NO build
env -u VIRTUAL_ENV uv run reyna-cli privacy-host status --json
env -u VIRTUAL_ENV uv run reyna-cli local-services speech transcribe-file --help
git diff --check
git status --short
```
Then repeat the signed live smoke test only with the user’s prior approval and confirm no new prompt occurs after a Python-only edit.
**Step 4: Commit**
```bash
git add src/reyna_cli/cli.py src/reyna_cli/signed_runner_client.py tests/test_reyna.py README.md docs/remaining-coverage-matrix.md
git diff --cached --check
git commit -m "feat: default speech execution to stable signed host"
```
## Release acceptance checklist
- [ ] Native host has the existing stable bundle ID and a non-ad-hoc Xcode signature.
- [ ] There is no generic Python/script/shell execution interface.
- [ ] Socket IPC remains owner-only and does not dispatch Python operations.
- [ ] Calendar, Contacts, Reminders native-host regressions and direct Notes CLI regressions remain green.
- [ ] A Python-only logic edit changes behavior without rebuilding/re-signing the app.
- [ ] The post-edit operation succeeds without a second TCC prompt.
- [ ] No new daemon, TCP listener, copied credential, or unbounded log was introduced.
- [ ] Direct fallback remains available until the owner explicitly approves its retirement.