23 KiB
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.appaccepts--python, readsREYNA_CLI_DIR/REYNA_CLI_PYTHONfrom environment or~/.reyna-cli.env/~/.config/reyna-cli/env, and runspython -m reyna_cli.cliwith 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-developmentskill 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-54chooses a workspace and Python interpreter from user configuration, then launchesapp_main.pythrough FoundationProcess(:67-80)./Users/adolforeyna/VoiceAgent/build_app.sh:57-83deliberately skips rebuilding/re-signing when native Swift sources are unchanged; Python files are copied independently. ItsAGENTS.mddescribes 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.plistdeclares bundle IDcom.reyna.cli.privacy-host, and the local bundle verification returned that ID with TeamIdentifierRHUM5U925W. src/reyna_cli/privacy_host.py:158-176launches the signed bundle through a per-user LaunchAgent, whileSources/ReynaCLIHostCore/AppEntry.swift:7-18currently 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-20explicitly 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:
- The native app accepts a constant allowlist of operation names only (initially
speech.transcribe_fileandspeech.live_session; add later operations deliberately). - 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. - 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.
- 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.
- 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:
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:
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
.venvimports Hermes’ Python 3.11pydantic_coreunder a Python 3.14 executable. - Swift has duplicate module-cache paths because both
/Users/adolforeyna/Projects/reyna-cliand/Users/adolforeyna/Projects/platform/reyna-cliare 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
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:
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:
{"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:
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:
{"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
env -u VIRTUAL_ENV uv run pytest tests/test_permission_runner.py -v
cd native/ReynaCLIHost && swift test --filter PythonRunnerProtocolTests
Step 6: Commit
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;
--socketnever dispatches to this runner.
Step 2: Extend runReynaCLIHost with one exact mode
Add only:
--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
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
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
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
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:
[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
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
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
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
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
- Change a harmless Python response marker (not Swift, plist, bundle ID, entitlement, or signing setting).
- Re-run the same signed-host operation.
- Confirm the changed Python behavior is present and macOS does not request permission again.
- 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
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
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
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.