From 77f8f354a32b5cde56a257514dc1e72d8f7abcc5 Mon Sep 17 00:00:00 2001 From: Bethvour Date: Thu, 13 Aug 2026 21:14:02 +0100 Subject: [PATCH 1/3] Fix release workflow: tag-gated publish and portable checksums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the release pipeline, both found while dry-running it: 1. An untagged `workflow_dispatch` run — the dry run the docs recommend — always failed at the final step. action-gh-release derives its tag from `github.ref`, which is a branch on a manual dispatch, so it errored with "requires a tag" after the build, signing, and notarization had all succeeded. Publishing is now gated on a tag ref, and untagged runs upload the DMG as a workflow artifact instead, so a dry run leaves no draft releases behind. 2. The `.sha256` file recorded the absolute runner path, so `shasum -c` failed for anyone who downloaded it. It now hashes the bare filename. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 18 +++++++++++++++++- docs/desktop/release.md | 7 +++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b1d919..b17ac9e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,10 +102,26 @@ jobs: run: | set -euo pipefail DMG="${{ steps.dmg.outputs.DMG_PATH }}" - shasum -a 256 "$DMG" | tee "$DMG.sha256" + # Hash the bare filename, not the absolute runner path, so that a + # downloader can `shasum -c Iris-.dmg.sha256` next to the DMG. + ( cd "$(dirname "$DMG")" \ + && shasum -a 256 "$(basename "$DMG")" | tee "$(basename "$DMG").sha256" ) echo "SHA256_FILE=$DMG.sha256" >> "$GITHUB_OUTPUT" + # Dry runs (workflow_dispatch) have no tag, and a GitHub Release requires + # one — so publish only on a tag and hand dry runs the DMG as an artifact. + - name: Upload DMG artifact (untagged dry runs) + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + uses: actions/upload-artifact@v4 + with: + name: Iris-${{ steps.dmg.outputs.DMG_VERSION }}-dmg + path: | + ${{ steps.dmg.outputs.DMG_PATH }} + ${{ steps.sums.outputs.SHA256_FILE }} + if-no-files-found: error + - name: Create draft GitHub Release + if: ${{ startsWith(github.ref, 'refs/tags/') }} uses: softprops/action-gh-release@v2 with: draft: true diff --git a/docs/desktop/release.md b/docs/desktop/release.md index 5af7944..1b5fa23 100644 --- a/docs/desktop/release.md +++ b/docs/desktop/release.md @@ -25,7 +25,9 @@ On a `v*` tag (or a manual `workflow_dispatch`), on a macOS runner: `notarytool submit --wait`, and `stapler staple` the app. 5. `scripts/make_dmg.sh` — build the drag-to-Applications DMG, sign it, print its SHA-256. -6. Publish a **draft** GitHub Release with the DMG and a `.sha256` file. +6. On a tag, publish a **draft** GitHub Release with the DMG and a `.sha256` + file. An untagged `workflow_dispatch` run uploads the same two files as a + workflow **artifact** instead — a GitHub Release requires a tag. Signing is done by our scripts (not Xcode) because the embedded runtime is added *after* Xcode would sign — which invalidates the seal. Re-signing inside-out is @@ -84,7 +86,8 @@ account password in CI and it doesn't break on 2FA. publish the draft. Use `workflow_dispatch` to dry-run the full build/sign/notarize path without -tagging (it still produces a draft release). +tagging. No release is created — download the DMG from the run's **Artifacts**. +Note this still spends a real notarization submission. ## Verifying (clean Mac) From b0b2f5299f8a09981e76f76200bb20c715afcc37 Mon Sep 17 00:00:00 2001 From: Bethvour Date: Tue, 22 Sep 2026 13:47:14 +0100 Subject: [PATCH 2/3] Prepare repository for public release Safety - Ignore mcp.json (real MCP config can hold tokens), signing material (*.p12, *.p8, *.pem, provisioning profiles), service-account keys, local SQLite state, DMGs, and editor settings. - Move the Apple DEVELOPMENT_TEAM out of the committed project into Config/Signing.xcconfig, which optionally includes a git-ignored Signing.local.xcconfig. Forks no longer inherit someone else's team. - Replace a real name in the Home screen UI-test fixture. - CI now runs with a read-only token and cancels superseded runs. Docs - Rewrite README: features, architecture, quick start from a fresh clone, configuration table, safety model, macOS app build, development. - Reorganize .env.example into required/optional sections (keys and defaults unchanged). - Remove the internal readiness-review checklist and references to the private implementation plan. Community - Add LICENSE (MIT, as already declared in pyproject.toml), CONTRIBUTING, SECURITY, issue and PR templates, and Dependabot for Actions and uv. - Add project URLs, keywords, and classifiers to pyproject.toml. Co-Authored-By: Claude Opus 5 --- .env.example | 94 ++-- .github/ISSUE_TEMPLATE/bug_report.yml | 49 +++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 19 + .github/dependabot.yml | 17 + .github/pull_request_template.md | 15 + .github/workflows/ci.yml | 11 +- .gitignore | 40 +- CONTRIBUTING.md | 70 +++ LICENSE | 21 + README.md | 407 +++++++++++------- SECURITY.md | 40 ++ apps/macos/Config/Signing.xcconfig | 15 + apps/macos/Iris.xcodeproj/project.pbxproj | 22 +- apps/macos/Iris/App/AppModel.swift | 2 +- apps/macos/IrisTests/HomeViewModelTests.swift | 4 +- apps/macos/project.yml | 14 +- docs/desktop/api-contract.md | 4 +- docs/desktop/architecture.md | 4 +- docs/desktop/packaging.md | 2 +- docs/desktop/readiness-review.md | 118 ----- docs/desktop/resilience-audit.md | 2 +- pyproject.toml | 18 +- tests/test_gateway.py | 6 +- 24 files changed, 660 insertions(+), 339 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 apps/macos/Config/Signing.xcconfig delete mode 100644 docs/desktop/readiness-review.md diff --git a/.env.example b/.env.example index f347dcb..0b9d2e5 100644 --- a/.env.example +++ b/.env.example @@ -1,38 +1,77 @@ -# Required for OpenAI Realtime, Responses, and computer-use. -UV_NO_EDITABLE=1 +# Iris configuration template. +# +# cp .env.example .env +# +# `.env` is git-ignored — never commit real keys. Only OPENAI_API_KEY is +# required; everything else has a working default or is an optional integration. + +# --- Required ---------------------------------------------------------------- +# OpenAI Realtime, Responses, and computer-use. OPENAI_API_KEY= -# Required for Google Cloud Vision OCR/labels. -# Example: /Users/you/.config/gcloud/iris-service-account.json +# --- Local gateway ----------------------------------------------------------- +# Bearer token for the local HTTP gateway (`./iris serve`). Generate one with: +# python3 -c 'import secrets; print(secrets.token_urlsafe(32))' +# The macOS app generates its own and keeps it in the Keychain. +IRIS_GATEWAY_TOKEN= +IRIS_STATE_DB=build/iris.sqlite3 + +# --- Optional integrations --------------------------------------------------- +# Google Cloud Vision OCR/labels. Path to a service-account JSON key, e.g. +# /Users/you/.config/gcloud/iris-service-account.json GOOGLE_APPLICATION_CREDENTIALS= +# Groq, for low-latency speech-to-text and intent routing (falls back to OpenAI). +GROQ_API_KEY= +IRIS_FAST_INTENT_MODEL=compound-mini +IRIS_STT_MODEL=whisper-large-v3-turbo + +# Phone push notifications: pushover (default) or ntfy. +IRIS_NOTIFY_PROVIDER=pushover +PUSHOVER_TOKEN= +PUSHOVER_USER= +IRIS_NTFY_SERVER=https://ntfy.sh +IRIS_NTFY_TOPIC= +IRIS_NTFY_TOKEN= +IRIS_NOTIFY_TIMEOUT_SECONDS=30 + +# Outbound email via Resend — meeting recaps and the send_email tool. +IRIS_EMAIL_PROVIDER=resend +RESEND_API_KEY= +IRIS_EMAIL_FROM= +IRIS_EMAIL_TO= + +# --- Identity & hotkeys ------------------------------------------------------ IRIS_AGENT_NAME=Iris IRIS_VOICE=marin IRIS_TOGGLE_HOTKEY=+ IRIS_KILL_HOTKEY=++k +# --- Models ------------------------------------------------------------------ IRIS_REALTIME_MODEL=gpt-realtime-2 +IRIS_REALTIME_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe IRIS_CHAT_MODEL=gpt-5.5 IRIS_VISION_MODEL=gpt-5.5 +IRIS_COMPUTER_USE_MODEL=computer-use-preview +IRIS_COMPUTER_USE_ENVIRONMENT=browser IRIS_EMBEDDING_MODEL=text-embedding-3-small + +# --- Memory ------------------------------------------------------------------ IRIS_MEMORY_EMBEDDINGS_ENABLED=true -# local = deterministic offline vectors, openai = API embeddings, auto = OpenAI if configured else local +# local = deterministic offline vectors, openai = API embeddings, +# auto = OpenAI if configured, else local. IRIS_MEMORY_EMBEDDING_PROVIDER=local -IRIS_COMPUTER_USE_MODEL=computer-use-preview -IRIS_COMPUTER_USE_ENVIRONMENT=browser -GROQ_API_KEY= -IRIS_FAST_INTENT_MODEL=compound-mini -IRIS_STT_MODEL=whisper-large-v3-turbo -IRIS_REALTIME_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe -IRIS_SCREENSHOT_INTERVAL_SECONDS=0.5 -IRIS_GATEWAY_TOKEN= -IRIS_STATE_DB=build/iris.sqlite3 + +# --- Safety & privacy -------------------------------------------------------- IRIS_AUTONOMY_LEVEL=L1 IRIS_MEETING_CONSENT_REQUIRED=true IRIS_MEETING_RETENTION_DAYS=30 -IRIS_LISTEN_SECONDS=2.0 -IRIS_WAKE_POLL_SECONDS=1.2 + +# --- Voice & turn-taking ----------------------------------------------------- IRIS_WAKE_WORDS=iris,hey iris +IRIS_WAKE_POLL_SECONDS=1.2 +IRIS_LISTEN_SECONDS=2.0 +IRIS_SCREENSHOT_INTERVAL_SECONDS=0.5 IRIS_BARGE_IN_ENABLED=true IRIS_BARGE_IN_GRACE_MS=250 IRIS_ECHO_SUPPRESSION_MS=800 @@ -42,26 +81,15 @@ IRIS_TURN_MAX_WAIT_MS=2500 IRIS_TURN_MIN_WORDS_FOR_IMMEDIATE_RESPONSE=4 IRIS_LIVE_VAD_SILENCE_MS=900 # Only controls terminal fallback output through macOS `say`. -# Live `./iris start --live` speech-to-speech still speaks through Realtime. +# Live `./iris start --live` speech-to-speech always speaks through Realtime. IRIS_SPEAK_RESPONSES=false IRIS_MAX_RESPONSE_CHARS=220 -# Terminal-first phone push. Pushover is the v1 default. ntfy is still supported. -IRIS_NOTIFY_PROVIDER=pushover -PUSHOVER_TOKEN= -PUSHOVER_USER= -IRIS_NTFY_SERVER=https://ntfy.sh -IRIS_NTFY_TOPIC= -IRIS_NTFY_TOKEN= -IRIS_NOTIFY_TIMEOUT_SECONDS=30 - -# Outbound email (Resend). Used for meeting recaps and the send_email tool. -IRIS_EMAIL_PROVIDER=resend -RESEND_API_KEY= -IRIS_EMAIL_FROM= -IRIS_EMAIL_TO= - -# Advanced profile overrides. Most users should run `./iris setup` instead. +# --- Profile overrides ------------------------------------------------------- +# Most users should run `./iris setup` instead. # IRIS_USER_NAME= # IRIS_USER_FIRST_NAME= # IRIS_USER_PRONOUNS= + +# --- Tooling ----------------------------------------------------------------- +UV_NO_EDITABLE=1 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..92a7bd3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,49 @@ +name: Bug report +description: Something isn't working as expected. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for the report! Please **don't include API keys, tokens, or + personal data**. Redact logs before pasting. Security issues go through + [private reporting](https://github.com/bethvourc/iris/security/advisories/new) instead. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you do, what did you expect, and what happened instead? + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + placeholder: | + 1. ./iris start --live + 2. Say "…" + 3. … + validations: + required: true + - type: dropdown + id: surface + attributes: + label: Where does it happen? + options: + - Terminal CLI (./iris) + - macOS app + - Gateway / API + - Other + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Paste the output of `./iris doctor` and `./iris status` (redact anything sensitive). + render: text + - type: input + id: version + attributes: + label: Version or commit + placeholder: v0.1.0 or a commit SHA diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d3748c8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/bethvourc/iris/security/advisories/new + about: Please report vulnerabilities privately, not as public issues. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..a345c55 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,19 @@ +name: Feature request +description: Suggest an idea or improvement. +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: What problem would this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What should Iris do? If the change touches safety or approvals, say how it stays safe. + - type: textarea + id: alternatives + attributes: + label: Alternatives considered diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1f9a277 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + actions: + patterns: ["*"] + + - package-ecosystem: uv + directory: / + schedule: + interval: monthly + groups: + python: + patterns: ["*"] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..441cff9 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +## Summary + + + +## Testing + + + +## Checklist + +- [ ] `uv run ruff check src tests` and `uv run pytest -q` pass +- [ ] Swift changes pass `swiftlint --strict`, `swiftformat --lint .`, and the Xcode tests +- [ ] No secrets, tokens, or personal data in code, tests, fixtures, or logs +- [ ] Docs updated (README, `docs/`, `.env.example`) if behavior or config changed +- [ ] Safety-relevant changes (risk tiers, approvals, gateway auth) are called out above diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b2dc8d..76de1aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,15 @@ on: branches: [main] pull_request: +# Read-only token: CI never needs to write to the repo, and PRs from forks run +# this workflow with untrusted code. +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: python: name: Python daemon @@ -48,7 +57,7 @@ jobs: release-scripts: name: Release scripts (ad-hoc dry run) - # Exercises the Step 7.2 pipeline logic — embed runtime, inside-out sign, + # Exercises the release pipeline logic — embed runtime, inside-out sign, # DMG — without any signing secrets, so regressions surface on every PR. # Single-arch build for speed; universality is covered by the runtime build. runs-on: macos-15 diff --git a/.gitignore b/.gitignore index 34d372e..02268dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,27 @@ -.DS_Store +# --- Secrets & local config ------------------------------------------------- +# Real config lives next to the committed *.example templates. .env .env.* !.env.example +mcp.json +*.pem +*.p12 +*.p8 +*.cer +*.mobileprovision +*.provisionprofile +*.keychain-db +*service-account*.json +apps/macos/Config/*.local.xcconfig + +# --- Local state ------------------------------------------------------------- +*.sqlite3 +*.sqlite3-* +iris-debug/ +iris_tmp_exec_test +*.log + +# --- Python ------------------------------------------------------------------ .venv/ __pycache__/ *.py[cod] @@ -13,12 +33,18 @@ htmlcov/ dist/ build/ *.egg-info/ -iris-debug/ -iris_tmp_exec_test -*.log -implementation/ -# Xcode +# --- Xcode / macOS ----------------------------------------------------------- xcuserdata/ DerivedData/ -*.xcresult \ No newline at end of file +*.xcresult +*.dmg +.DS_Store + +# --- Editors & tools --------------------------------------------------------- +.idea/ +.vscode/ +.claude/settings.local.json + +# Private working notes +implementation/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a2499e6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,70 @@ +# Contributing to Iris + +Thanks for your interest in improving Iris. This guide covers local setup, the +checks your change needs to pass, and what we look for in a pull request. + +## Ground rules + +- **Never commit secrets.** Real config goes in `.env` and `mcp.json`, and both + are git-ignored. If you add a new setting, add it to `.env.example` with an + empty or safe default, and never with a real value. +- **Safety changes need extra care.** Iris acts on a real Mac. Any change to + `src/iris/safety.py`, the approval flow, gateway auth, or a tool's risk tier + should explain why it's safe, and it should come with tests. +- **Keep the gateway local.** The daemon binds to `127.0.0.1` and requires a + bearer token on every non-exempt route. Don't widen `AUTH_EXEMPT_PATHS` + unless you've discussed it in an issue first. +- For large or architectural changes, open an issue first so we can agree on + the approach before you invest time. + +## Python core + +```bash +uv sync --dev --no-editable +cp .env.example .env # OPENAI_API_KEY is only needed for live features +uv run ruff check src tests +uv run pytest -q +``` + +Tests must run offline and without macOS permissions. Mock network calls and +system APIs rather than calling them. If you add a gateway route, add it to +`_PROTECTED_ROUTES` in `tests/test_gateway.py`. The deny-by-default scan fails +until you do. + +## macOS app + +```bash +brew install xcodegen swiftlint swiftformat +cd apps/macos +xcodegen generate +swiftlint --strict +swiftformat --lint . +xcodebuild test -project Iris.xcodeproj -scheme Iris \ + -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO +``` + +`project.yml` is the source of truth for the Xcode project. Edit it rather than +the `.xcodeproj`, run `xcodegen generate`, and commit both. + +To sign locally, put your team in `apps/macos/Config/Signing.local.xcconfig`. +That file is git-ignored, and UI tests need it: + +``` +DEVELOPMENT_TEAM = +``` + +If the wire format between the app and the daemon changes, update +[`docs/desktop/api-contract.md`](docs/desktop/api-contract.md) in the same PR. + +## Pull requests + +1. Fork the repo and create a branch from `main`. +2. Keep each PR focused on one change. Write a clear description of what + changed and why. +3. Make sure CI passes: Python lint and tests, SwiftLint, SwiftFormat, and the + Xcode tests. +4. Add or update tests for any behavior you changed. +5. Update the README or `docs/` if you changed user-facing behavior. + +By contributing, you agree that your contributions are licensed under the +[MIT License](LICENSE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..03374d4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Clinton Imaro and the Iris contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 01eb84a..43c4d75 100644 --- a/README.md +++ b/README.md @@ -1,170 +1,171 @@ # Iris -Iris is a local-first Mac agent that runs from the terminal. It supports text, -voice, live screen awareness, safe Mac/browser actions, local state, connectors, -watchers, approvals, and audit logs. +**A local-first voice and vision agent for macOS.** + +[![CI](https://github.com/bethvourc/iris/actions/workflows/ci.yml/badge.svg)](https://github.com/bethvourc/iris/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +![Platform: macOS](https://img.shields.io/badge/platform-macOS%2014%2B-lightgrey.svg) +![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg) + +Iris sees your screen, listens when you ask, and operates your Mac and browser +on your behalf: opening apps, reading pages, clicking through UIs, playing +media, drafting messages. Every action passes through a safety gate first. +It runs as a terminal CLI or as a native menu-bar app. State, memory, and audit +logs stay on your machine. + +--- + +## Contents + +- [Features](#features) +- [How it works](#how-it-works) +- [Quick start](#quick-start) +- [Configuration](#configuration) +- [Usage](#usage) +- [Safety model](#safety-model) +- [macOS app](#macos-app) +- [Extending Iris](#extending-iris) +- [Development](#development) +- [Documentation](#documentation) +- [Contributing](#contributing) · [Security](#security) · [License](#license) + +## Features + +- **Voice and text.** Live speech-to-speech over the OpenAI Realtime API, with + wake words (`iris`, `hey iris`), barge-in, and echo suppression. There's also + a plain text REPL for quiet environments. +- **Screen awareness.** Periodic screen capture and description, plus optional + Google Cloud Vision OCR. +- **Mac and browser control.** Native UI automation through the macOS + Accessibility API, Chrome via the DevTools Protocol, and a computer-use model + as a fallback. +- **Safety first.** Every action is classified as *low-risk*, *sensitive*, or + *blocked* before it runs. A global kill-switch hotkey pauses all automation, + and an audit trail records what happened. +- **Local memory.** A SQLite-backed memory graph with offline embeddings by + default, and a knowledge base you can ingest folders of notes into. +- **Extensible.** Connectors are JSON manifests, not code. Iris also speaks + [MCP](https://modelcontextprotocol.io), and ships recipes, watchers, + workflows, and evals. +- **Native desktop app.** A SwiftUI menu-bar app with a global hotkey, a + Siri-style voice overlay, an activity feed, and approvals. It's backed by the + same Python core. + +## How it works -## Iris for macOS (desktop app) - -Beyond the terminal CLI, Iris ships as a native macOS app — a menu-bar presence -with a global hotkey, a Siri-style voice overlay, and a main window for activity -and settings — backed by the same Python core running as a supervised local -daemon (`iris serve`). The Swift/SwiftUI app lives in [`apps/macos/`](apps/macos). - -The app spawns and supervises the daemon (health polling, restart with backoff, -crash-loop detection, adopt-if-already-running), binds `127.0.0.1` only, and -holds the gateway token in the Keychain — injected into the daemon via -environment, never written to disk. In a shipped build, state lives in -`~/Library/Application Support/Iris/` and logs in `~/Library/Logs/Iris/`. - -- **Architecture & trust boundaries** — [`docs/desktop/architecture.md`](docs/desktop/architecture.md) -- **API contract** (voice / activity / settings / health, SSE catalog) — [`docs/desktop/api-contract.md`](docs/desktop/api-contract.md) -- **Packaging & release** (signing, notarization, DMG) — [`docs/desktop/packaging.md`](docs/desktop/packaging.md), [`docs/desktop/release.md`](docs/desktop/release.md) -- **Operations runbook** (logs, health checks, reset, uninstall, failure signatures) — [`docs/desktop/runbook.md`](docs/desktop/runbook.md) -- **Resilience audit** (fault-injection drills) — [`docs/desktop/resilience-audit.md`](docs/desktop/resilience-audit.md) - -## Requirements - -- macOS -- Python 3.11+ -- `uv` -- OpenAI API key - -Optional: - -- Google Vision credentials for OCR/extra screen reading -- Pushover credentials for phone notifications - -## Setup - -```bash -cd /Users//Documents/Projects/iris -uv sync --dev --no-editable -cp .env.example .env -``` - -Edit `.env` and add at least: - -```bash -OPENAI_API_KEY=... ``` - -Optional phone alerts: - -```bash -IRIS_NOTIFY_PROVIDER=pushover -PUSHOVER_TOKEN=... -PUSHOVER_USER=... -``` - -Initialize local state: - -```bash -./iris init -./iris status -``` - -Personalize Iris: - -```bash -./iris setup -``` - -For non-interactive setup: - -```bash -./iris setup --name Bethvour --pronouns he/him +┌──────────────────────────────┐ ┌──────────────────────────────────┐ +│ Iris.app (Swift / SwiftUI) │ HTTP │ Python core (`iris serve`) │ +│ menu bar · hotkey · overlay │ ◄────► │ agent loop · tools · safety │ +│ activity · approvals │ SSE │ memory · connectors · audit │ +└──────────────────────────────┘ └───────────────┬──────────────────┘ + 127.0.0.1 only · bearer token in Keychain │ + ▼ + OpenAI · Groq · Accessibility · Chrome CDP ``` -If you use the local HTTP gateway, set a private token first: +The Python core does all the reasoning and holds all the state. You can drive +it directly from the terminal (`./iris start`), or let the macOS app supervise +it as a local daemon. The gateway binds to `127.0.0.1` only, and every route +except `/health` and the static UI shells requires a bearer token. -```bash -IRIS_GATEWAY_TOKEN=$(python -c 'import secrets; print(secrets.token_urlsafe(32))') -``` +For the full design, including trust boundaries and failure modes, see +[`docs/desktop/architecture.md`](docs/desktop/architecture.md). -## macOS Permissions +## Quick start -Run: +**Requirements:** macOS, Python 3.11+, [`uv`](https://docs.astral.sh/uv/), and +an OpenAI API key. ```bash -./iris permissions +git clone https://github.com/bethvourc/iris.git +cd iris +uv sync --dev --no-editable +cp .env.example .env # then set OPENAI_API_KEY in .env +./iris init # create local state +./iris setup # personalize: your name, pronouns, preferences +./iris permissions # check which macOS permissions are missing ``` -Then grant the terminal app running Iris these permissions in macOS System -Settings: +Grant the terminal app that runs Iris these permissions in **System Settings → +Privacy & Security**, then restart Iris: - Microphone - Screen Recording - Accessibility - Automation -Restart Iris after changing permissions. - -## Start Iris - -Text mode: +Start a session: ```bash -./iris start +./iris start # text mode +./iris start --live # live voice mode ``` -Live voice mode: +Inside a session you can use `/listen [seconds]`, `/reset`, `kill`, `resume`, +and `quit`. -```bash -./iris start --live -``` +Run `./iris doctor` whenever something doesn't work. It checks permissions, +providers, connectors, and control backends. -Wake words default to: +## Configuration -```text -iris, hey iris -``` +All configuration comes from environment variables, loaded from `.env`. +[`.env.example`](.env.example) documents every option. Only `OPENAI_API_KEY` +is required. + +| Variable | Purpose | +|----------|---------| +| `OPENAI_API_KEY` | **Required.** Realtime voice, reasoning, vision, computer-use. | +| `IRIS_GATEWAY_TOKEN` | Bearer token for the local HTTP gateway (`./iris serve`). | +| `GROQ_API_KEY` | Optional. Low-latency STT and intent routing; falls back to OpenAI. | +| `GOOGLE_APPLICATION_CREDENTIALS` | Optional. Service-account JSON path for Google Vision OCR. | +| `PUSHOVER_TOKEN`, `PUSHOVER_USER` | Optional. Phone push notifications (or use `ntfy`). | +| `RESEND_API_KEY` | Optional. Outbound email for meeting recaps and `send_email`. | +| `IRIS_*_MODEL` | Model routing overrides. Run `./iris providers` to see the active routes. | -Inside `./iris start`, useful commands: +To use the gateway, generate a token: -```text -/listen -/listen 1.5 -/reset -kill -resume -quit +```bash +python3 -c 'import secrets; print(secrets.token_urlsafe(32))' ``` -## Common Commands +Local state is stored in SQLite at `build/iris.sqlite3` by default. You can +change this with `IRIS_STATE_DB`. + +## Usage + +
+Status and diagnostics ```bash ./iris status ./iris providers -./iris profile show -./iris profile set --name Bethvour --pronouns he/him -./iris setup +./iris doctor ./iris test-screen ./iris test-vision ./iris test-voice --listen -./iris control health -./iris doctor -./iris browser start-cdp -./iris browser tabs -./iris browser current -./iris controls apps -./iris controls inspect -./iris controls find "search" -./iris recipes list -./iris recipes show spotify_play_song ./iris notify test -./iris approvals -./iris audit +./iris diagnostics last-run ``` +
-Watchers: +
+Mac and browser control ```bash -./iris watch add --kind web --name "Example" --target https://example.com --expected Example -./iris watch list +./iris control health +./iris controls apps +./iris controls inspect +./iris controls find "search" +./iris browser start-cdp +./iris browser tabs +./iris browser current ``` +
-Memory and knowledge: +
+Memory and knowledge ```bash ./iris memory add --category project --content "Iris is local-first." @@ -175,56 +176,104 @@ Memory and knowledge: ./iris knowledge search "agent runtime" ``` -Connectors: +With the gateway running, the visual memory browser is available at +. +
+ +
+Background runtime, tasks, and approvals ```bash -./iris connectors list -./iris connectors health -./iris connectors show stripe -./iris connectors enable google-ads +./iris serve +./iris sessions list +./iris tasks list +./iris tasks show +./iris tasks run-next +./iris tasks cancel +./iris tasks resume +./iris approvals +./iris approve +./iris deny +./iris audit ``` +
-Agent evals: +
+Connectors, recipes, watchers, and evals ```bash +./iris connectors list +./iris connectors health +./iris connectors enable google-ads +./iris recipes list +./iris recipes show spotify_play_song +./iris watch add --kind web --name "Example" --target https://example.com --expected Example +./iris watch list ./iris evals list ./iris evals run ``` +
+ +Run `./iris --help` or `./iris --help` to see every command. + +## Safety model + +Before an action runs, Iris classifies it into one of three tiers: + +| Tier | Behavior | Examples | +|------|----------|----------| +| **Low-risk** | Runs automatically | Reading the screen, opening an app, browsing | +| **Sensitive** | Waits for your explicit approval | Sending messages or email, running shell commands, editing or moving files, installing software, creating calendar events | +| **Blocked** | Refused | Payments, entering credentials, changing security or privacy settings, deleting files, destructive shell commands (`rm -rf`, `sudo`, `git reset --hard`, …) | + +The kill-switch hotkey (`IRIS_KILL_HOTKEY`, default ⌃⌥K) +pauses all automation immediately. Actions are recorded in a local audit trail +(`./iris audit`). Meeting mode requires your consent by default +(`IRIS_MEETING_CONSENT_REQUIRED`). -Background runtime: +## macOS app + +The native app lives in [`apps/macos/`](apps/macos). It spawns and supervises +the Python daemon, with health polling, restart with backoff, and crash-loop +detection. It keeps the gateway token in the Keychain and passes it to the +daemon through the environment, so the token is never written to disk. Shipped +builds store state in `~/Library/Application Support/Iris/` and logs in +`~/Library/Logs/Iris/`. + +**Build from source** (requires Xcode 16+ and macOS 14+): ```bash -./iris serve -./iris sessions list -./iris tasks list -./iris tasks show -./iris tasks run-next -./iris tasks cancel -./iris tasks resume +brew install xcodegen # the project is generated from project.yml +cd apps/macos +xcodegen generate +open Iris.xcodeproj ``` -Open the visual memory browser after starting the gateway: +To run the tests without signing: -```text -http://127.0.0.1:8765/memory-browser +```bash +xcodebuild test -project Iris.xcodeproj -scheme Iris \ + -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO ``` -Gateway API routes other than `/health` and the static `/memory-browser` shell require: +To sign locally (UI tests need this), create +`apps/macos/Config/Signing.local.xcconfig`. The file is git-ignored. -```text -Authorization: Bearer +``` +DEVELOPMENT_TEAM = ``` -## Connector Manifests +If you fork the project, you'll also need to change the `com.bethvour.*` bundle +identifiers in `project.yml` to your own. -Connectors are JSON manifests, not router code. Add connector files under: +Signed, notarized DMG releases are produced by +[`release.yml`](.github/workflows/release.yml). See +[`docs/desktop/release.md`](docs/desktop/release.md) for how that works. -```text -connectors/ -~/.iris/connectors/ -``` +## Extending Iris -Example: +**Connectors** are JSON manifests. Iris loads them from `connectors/` and +`~/.iris/connectors/`: ```json { @@ -240,9 +289,57 @@ Example: } ``` -## Notes +**MCP servers:** copy [`mcp.json.example`](mcp.json.example) to `mcp.json`, +which is git-ignored, and enable the servers you want. Keep tokens in `.env` +and reference them as `${VAR}`. Don't paste tokens inline. + +**Recipes** (`recipes/builtin.json`) are reusable, multi-step action plans. +**Evals** (`evals/agent_tasks.json`) check that requests get routed to the +right tools. + +## Development + +```bash +uv sync --dev --no-editable +uv run ruff check src tests # lint +uv run pytest -q # tests +``` + +CI runs on every pull request. It runs Python lint and tests, SwiftLint and +SwiftFormat, the Xcode unit tests, and an ad-hoc-signed dry run of the release +pipeline. + +``` +src/iris/ Python core: agent loop, tools, safety, memory, gateway, CLI +tests/ pytest suite +apps/macos/ SwiftUI app (Iris), shared framework (IrisKit), tests, release scripts +connectors/ built-in connector manifests +recipes/ built-in action recipes +evals/ agent capability eval cases +docs/desktop/ architecture, API contract, packaging, release, runbook +``` + +## Documentation + +| Document | What's in it | +|----------|--------------| +| [Architecture](docs/desktop/architecture.md) | Components, trust boundaries, failure modes | +| [API contract](docs/desktop/api-contract.md) | Gateway routes and the SSE event catalog | +| [Packaging](docs/desktop/packaging.md) | The embedded, relocatable Python runtime | +| [Release](docs/desktop/release.md) | Signing, notarization, DMG, and rollback | +| [Runbook](docs/desktop/runbook.md) | Logs, health checks, reset, uninstall, and failure signatures | +| [Resilience audit](docs/desktop/resilience-audit.md) | Fault-injection drills and their results | + +## Contributing + +Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before you +open a pull request. + +## Security + +Please don't open public issues for vulnerabilities. See +[SECURITY.md](SECURITY.md) for how to report one privately. + +## License -- Low-risk actions can run automatically. -- Sensitive actions require approval. -- Destructive, credential, payment, and security actions are blocked or gated. -- Local state is stored in SQLite under `build/` by default. +[MIT](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e0d882b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# Security Policy + +Iris can see your screen, hear your microphone, and operate your Mac, so we +take security reports seriously. + +## Reporting a vulnerability + +**Please don't open a public issue.** Report vulnerabilities privately through +[GitHub Security Advisories](https://github.com/bethvourc/iris/security/advisories/new). + +Please include: + +- a description of the issue and its impact +- steps to reproduce, or a proof of concept +- the affected version or commit, and your macOS version + +We aim to acknowledge reports within a few days. We'll keep you updated while +we work on a fix, and we're happy to credit you in the release notes. + +## Supported versions + +Iris is pre-1.0. Security fixes go to the latest release and to `main`. + +## Scope + +These are especially in scope: + +- **Safety gate bypasses:** a sensitive or blocked action that runs without + approval. +- **Gateway exposure:** a route reachable without a valid token, or the daemon + listening on anything other than `127.0.0.1`. +- **Secret leakage:** API keys or the gateway token showing up in logs, + diagnostics bundles, API responses, or on disk outside the Keychain or `.env`. +- **Prompt injection:** on-screen or web content that makes Iris take actions + the user didn't ask for. +- **Code-signing or update integrity** of the macOS app. + +Out of scope: vulnerabilities in third-party MCP servers or connectors that you +choose to enable, and attacks that need someone who already has full control of +your user account. diff --git a/apps/macos/Config/Signing.xcconfig b/apps/macos/Config/Signing.xcconfig new file mode 100644 index 0000000..4cfac93 --- /dev/null +++ b/apps/macos/Config/Signing.xcconfig @@ -0,0 +1,15 @@ +// Code-signing settings shared by every target. +// +// DEVELOPMENT_TEAM is deliberately empty so the project opens and builds for +// anyone who clones it. To sign locally with your own Apple Development +// certificate (XCUITest needs the app and its test runner on one Team ID), +// create Config/Signing.local.xcconfig next to this file (it is git-ignored): +// +// DEVELOPMENT_TEAM = ABCDE12345 +// +// CI builds pass CODE_SIGNING_ALLOWED=NO; release builds are re-signed with a +// Developer ID by scripts/sign_and_notarize.sh, so neither depends on this file. + +DEVELOPMENT_TEAM = + +#include? "Signing.local.xcconfig" diff --git a/apps/macos/Iris.xcodeproj/project.pbxproj b/apps/macos/Iris.xcodeproj/project.pbxproj index f81b102..3eedc1c 100644 --- a/apps/macos/Iris.xcodeproj/project.pbxproj +++ b/apps/macos/Iris.xcodeproj/project.pbxproj @@ -175,6 +175,7 @@ 5D0A88C56A8806CDBA9729B9 /* IrisAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrisAPIError.swift; sourceTree = ""; }; 5D70DB5CBC94B0FA1EEDA34D /* TokenStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenStoreTests.swift; sourceTree = ""; }; 5EFCB894FA1687C8B5500551 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; + 653055BB5680C071899B987B /* Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.xcconfig; sourceTree = ""; }; 66AE68225F288235425EF50A /* AppModel+Launch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+Launch.swift"; sourceTree = ""; }; 6882310BBE5EEEB7EDE09E4F /* SiriNewIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiriNewIcon.swift; sourceTree = ""; }; 6B1FBBA4C38C01CDA146F874 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -274,6 +275,7 @@ 1F94CDDECE10DBA2BC5859A9 = { isa = PBXGroup; children = ( + CBF8ACA79B15A7F78BD703B5 /* Config */, AD693C86B9C5C60C0F865BD7 /* Iris */, 18243BA1F17CCDBFE0ADA2AD /* IrisKit */, AC6BC111CD761B97AB2DD0A4 /* IrisTests */, @@ -420,6 +422,14 @@ path = Design; sourceTree = ""; }; + CBF8ACA79B15A7F78BD703B5 /* Config */ = { + isa = PBXGroup; + children = ( + 653055BB5680C071899B987B /* Signing.xcconfig */, + ); + path = Config; + sourceTree = ""; + }; D2070894243A80683C258643 /* Products */ = { isa = PBXGroup; children = ( @@ -549,19 +559,19 @@ LastUpgradeCheck = 1430; TargetAttributes = { 2D904A5F66C66CEBD334E4E1 = { - DevelopmentTeam = 264K2T9HX2; + DevelopmentTeam = ""; ProvisioningStyle = Automatic; }; 85901BA009A898EC765CDF6A = { - DevelopmentTeam = 264K2T9HX2; + DevelopmentTeam = ""; ProvisioningStyle = Automatic; }; DA6B0FE32874865F8A6DAF74 = { - DevelopmentTeam = 264K2T9HX2; + DevelopmentTeam = ""; ProvisioningStyle = Automatic; }; F63AF79DEC25987DAB61D34C = { - DevelopmentTeam = 264K2T9HX2; + DevelopmentTeam = ""; ProvisioningStyle = Automatic; TestTargetID = DA6B0FE32874865F8A6DAF74; }; @@ -784,6 +794,7 @@ }; 41B0BFF19F1D5176EE351013 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 653055BB5680C071899B987B /* Signing.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -819,7 +830,6 @@ COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = 264K2T9HX2; ENABLE_HARDENED_RUNTIME = YES; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -958,6 +968,7 @@ }; FF9378E27F3D76AA64B7635A /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 653055BB5680C071899B987B /* Signing.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -993,7 +1004,6 @@ COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = 264K2T9HX2; ENABLE_HARDENED_RUNTIME = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; diff --git a/apps/macos/Iris/App/AppModel.swift b/apps/macos/Iris/App/AppModel.swift index dc5a70b..a127cab 100644 --- a/apps/macos/Iris/App/AppModel.swift +++ b/apps/macos/Iris/App/AppModel.swift @@ -146,7 +146,7 @@ final class AppModel { if let scenario = Self.argumentValue(after: "--ui-test-home") { return HomeViewModel( client: ScriptedActivityFetcher(scenario: scenario), - greetingName: "Bethvour" + greetingName: "Alex" ) } return HomeViewModel(client: apiClient) diff --git a/apps/macos/IrisTests/HomeViewModelTests.swift b/apps/macos/IrisTests/HomeViewModelTests.swift index c27cedc..4131967 100644 --- a/apps/macos/IrisTests/HomeViewModelTests.swift +++ b/apps/macos/IrisTests/HomeViewModelTests.swift @@ -45,8 +45,8 @@ final class HomeViewModelTests: XCTestCase { // MARK: - Greeting func testGreetingUsesProvidedName() { - let model = HomeViewModel(client: FakeActivityFetcher([]), greetingName: "Bethvour") - XCTAssertEqual(model.greeting, "Welcome back, Bethvour") + let model = HomeViewModel(client: FakeActivityFetcher([]), greetingName: "Alex") + XCTAssertEqual(model.greeting, "Welcome back, Alex") } func testGreetingFallsBackWhenNameMissing() { diff --git a/apps/macos/project.yml b/apps/macos/project.yml index 9a1dd50..d4bc670 100644 --- a/apps/macos/project.yml +++ b/apps/macos/project.yml @@ -8,16 +8,18 @@ options: macOS: "14.0" createIntermediateGroups: true +# DEVELOPMENT_TEAM lives in Config/Signing.xcconfig, which optionally includes a +# git-ignored Config/Signing.local.xcconfig — see that file for local signing. +configFiles: + Debug: Config/Signing.xcconfig + Release: Config/Signing.xcconfig + settings: base: MACOSX_DEPLOYMENT_TARGET: "14.0" SWIFT_VERSION: "6.0" ENABLE_HARDENED_RUNTIME: YES CODE_SIGN_STYLE: Automatic - # Local Apple Development signing so app + UI test runner share a Team - # ID (XCUITest requires it). CI overrides with CODE_SIGNING_ALLOWED=NO - # for unit tests; Developer ID release signing arrives in Step 7.2. - DEVELOPMENT_TEAM: 264K2T9HX2 CURRENT_PROJECT_VERSION: 1 MARKETING_VERSION: 0.1.0 @@ -44,8 +46,8 @@ targets: ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon # Embed the relocatable Python runtime into Contents/Resources. The script # no-ops for Debug (dev mode runs the daemon from the repo via uv), so this - # only adds time to Release builds. Runs after signing of the app's own code - # but the runtime is re-signed in Step 7.2. + # only adds time to Release builds. Runs after signing of the app's own code; + # scripts/sign_and_notarize.sh re-signs the runtime for release. postBuildScripts: - name: Embed Python runtime script: '"${SRCROOT}/scripts/package_python.sh"' diff --git a/docs/desktop/api-contract.md b/docs/desktop/api-contract.md index 537e6a8..5b47518 100644 --- a/docs/desktop/api-contract.md +++ b/docs/desktop/api-contract.md @@ -2,7 +2,7 @@ Status: **frozen contract** for the desktop implementation. Swift `Codable` models (`apps/macos/IrisKit/Models.swift`) and Python handlers are both written against this document. -Changes are additive-only once Phase 2 starts; anything breaking requires bumping +Changes are additive-only; anything breaking requires bumping `contract_version` and updating both sides in one change. Existing endpoints that the desktop app reuses (approvals, messages, runs) are documented in @@ -144,7 +144,7 @@ Auth required (header-based; native clients can set headers on SSE). | `event:` | `data` payload | Notes | |---|---|---| | `state` | `{"state": "...", "session": {...}\|null, "meeting_active": bool}` | Snapshot; first frame on connect and on every state transition. | -| `wake_detected` | `{"source": "wake_word"\|"in_session", "phrase": "hey iris"}` | Phase 6 local detector, or in-session wake while gated. | +| `wake_detected` | `{"source": "wake_word"\|"in_session", "phrase": "hey iris"}` | On-device wake-word detector, or in-session wake while gated. | | `user_transcript` | `{"text": "…", "final": true}` | Completed user turn transcript (post wake-word stripping). | | `assistant_delta` | `{"text": "…"}` | Streamed assistant transcript delta. | | `assistant_done` | `{"text": "…"}` | Full deduplicated assistant turn text. | diff --git a/docs/desktop/architecture.md b/docs/desktop/architecture.md index 6f82a7d..abfd16f 100644 --- a/docs/desktop/architecture.md +++ b/docs/desktop/architecture.md @@ -1,6 +1,6 @@ # Iris for macOS — System Architecture -Status: authoritative for the desktop implementation (see `implementation/desktop-macos-plan.md`). +Status: authoritative for the desktop app. Companion document: [`api-contract.md`](api-contract.md) — the frozen wire contract between the Swift app and the Python daemon. If this document and the code disagree, fix one of them in the same change. @@ -137,7 +137,7 @@ a pair stored in app preferences). 5. Hotkey again or Esc → `POST /voice/interrupt` (mid-speech) or `POST /voice/stop` (end session). Session end emits `session_ended`; overlay shows a brief summary and dismisses. -### Wake word (Phase 6, opt-in, default off) +### Wake word (opt-in, default off) 1. Daemon runs an on-device openWakeWord loop (no network). On detection it emits `wake_detected` on `/voice/events` and auto-starts the realtime session. 2. App (already subscribed) shows the overlay and joins the running session via the snapshot. diff --git a/docs/desktop/packaging.md b/docs/desktop/packaging.md index eefca7d..1d8167c 100644 --- a/docs/desktop/packaging.md +++ b/docs/desktop/packaging.md @@ -8,7 +8,7 @@ repo checkout via `uv`. This document covers the bundle layout, how the runtime is built, and how to update it. Code signing and notarization of the embedded binaries are covered -separately in Step 7.2. +separately in [`release.md`](release.md). ## Bundle layout diff --git a/docs/desktop/readiness-review.md b/docs/desktop/readiness-review.md deleted file mode 100644 index 537e2fc..0000000 --- a/docs/desktop/readiness-review.md +++ /dev/null @@ -1,118 +0,0 @@ -# Production Readiness Review (Step 7.5) - -A four-perspective review of the release build — **User**, **Operator**, -**Security**, **Maintainer**. The bar is "would a strong team ship this," not -"does it compile." Findings are logged here with resolutions; the step closes -when every suite is green and this doc has zero open findings. - -- **Status legend:** ✅ verified · ⚠️ verified with caveat · 🔲 manual, pending a signed release build -- **Last automated run:** 2026-06-20 - ---- - -## 1. Maintainer — suites, contracts, dead code - -| Gate | Command | Result | -|------|---------|--------| -| Python tests | `uv run pytest` | ✅ 266 passed | -| Python lint | `uv run ruff check .` | ✅ clean | -| Swift unit tests | `xcodebuild test -scheme Iris -only-testing:IrisTests` | ✅ 158 passed | -| Swift UI tests | `xcodebuild test -scheme IrisUITests -only-testing:IrisUITests/ResilienceUITests` | ✅ 12 passed | -| Swift lint | `swiftlint lint` (Iris, IrisKit, IrisTests, IrisUITests) | ✅ clean | -| Swift format | `swiftformat --lint` (same dirs) | ✅ 0/74 require formatting | - -> No separate type checker is configured (`pyproject.toml` dev deps: `pytest`, -> `ruff`); `ruff` + `pytest` are the Python gates. `swiftformat --lint .` reports -> issues only inside `build/xcode-derived/SourcePackages/` (the third-party -> KeyboardShortcuts checkout) — excluded from our scope. - -- **Contract-doc accuracy:** the routes in `api-contract.md` (`/voice/*`, - `/activity`, `/settings`, `/secrets`, extended `/health`) match the gateway's - dispatch table; the auth-exempt set matches `AUTH_EXEMPT_PATHS`. The full route - list is now pinned by the deny-by-default scan (§3), so contract drift fails a - test. -- **Dead-code sweep:** spot sweep of the desktop surfaces found no orphaned - types or unused `--ui-test-*` hooks; the scripted providers are all referenced - by `AppModel`. (Full cross-module sweep is part of the maintainer sign-off - against a release build.) - -**Open findings:** none. - ---- - -## 2. Security - -| Check | Status | Evidence | -|-------|--------|----------| -| Binds `127.0.0.1` only (no `0.0.0.0`) | ✅ | `gateway.serve(host="127.0.0.1")`, `cli.py --host` default `127.0.0.1`; no `0.0.0.0` anywhere | -| Auth required on every non-exempt route | ✅ | Deny-by-default scan: `test_every_protected_route_denies_by_default`, `test_auth_exempt_set_is_locked`, `test_no_protected_route_is_silently_exempt` | -| Constant-time token comparison | ✅ | `authorize()` uses `hmac.compare_digest` | -| Token never written to disk | ✅ | Held in Keychain; injected via `IRIS_GATEWAY_TOKEN` env only (`DaemonManager.launchProcess`) | -| Secrets absent from logs | ✅ | `test_requests_are_logged_without_leaking_tokens`; request logger records path/status/duration, never bodies/tokens | -| Secrets never serialize into API/UI | ✅ | `test_secret_values_never_serialize`; settings/secrets report `is_set` only | -| Secrets absent from diagnostics zip | ✅ | `DiagnosticsTests.testRedactScrubsKnownKeyShapes`, `testBundleContainsNoSecrets`; every file redacted before write | -| Settings whitelist enforced | ✅ | `SETTING_SPECS` whitelist; `test_put_unknown_key_is_rejected_with_envelope`, `test_put_env_managed_key_returns_409` | -| Least-privilege entitlements | ✅ | App: microphone only (`device.audio-input`). Hardened-runtime exceptions scoped to the embedded interpreter (`scripts/iris-runtime.entitlements`), not the app | -| Signed binaries (`codesign --verify --deep`) | 🔲 | Requires a signed release artifact — run during the release/notarization pass (7.2) | - -**Hardening added this pass:** the deny-by-default route scan (`tests/test_gateway.py`) -locks `AUTH_EXEMPT_PATHS` to `{/health, /dashboard, /memory-browser}` and asserts -every other route rejects missing/wrong tokens — so a future route that bypasses -auth, or an accidental exempt-set widening, fails CI. - -**Open findings:** none automatable-side. `codesign --verify --deep --strict` -and a `spctl --assess` gatekeeper check remain for the signed build. - ---- - -## 3. User — full journey (manual, signed build) - -Run on a clean account; note every rough edge against the design standard (flat -surfaces, hairline borders, one accent, no gradients/glows/toasts). Capture a -screenshot of each surface into `docs/desktop/screenshots/readiness/`. - -1. **Install** — mount DMG, drag to Applications, first launch (Gatekeeper - prompt is clean). 🔲 -2. **Onboard** — welcome → API key → microphone → system access → done; denied - permissions stay skippable (degraded, never a dead end). 🔲 -3. **First session** — hotkey opens the overlay; listening → thinking → - speaking render; transcript readable. 🔲 -4. **Interrupt** — barge-in stops playback instantly. 🔲 -5. **Review activity** — main window activity feed populates; a detail opens. 🔲 -6. **Change settings** — voice/port/launch-at-login persist across relaunch. 🔲 -7. **Quit / relaunch** — clean shutdown; daemon adopts or restarts; no orphan - process, no stale overlay. 🔲 - -(The UI destinations for failure states are already covered by -`ResilienceUITests`; this pass is the happy-path journey on a real build.) - ---- - -## 4. Operator — diagnose three induced faults (manual, signed build) - -Using **only** `daemon.log` + the [runbook](runbook.md) (no source), diagnose and -recover from three induced faults. Confirms the logs + runbook are sufficient. - -1. **Port conflict** — occupy `8765`, launch; expect to find `gateway.port_in_use` - in the log and recover via runbook §5. 🔲 -2. **Token mismatch** — delete the Keychain item while running; expect 401s, - diagnose via the runbook §7 table, recover via §4a. 🔲 -3. **Corrupt settings** — write garbage `settings.json`; expect - `settings.corrupt_file_ignored`, confirm graceful defaults, rewrite via §4b. 🔲 - -(The daemon-side behavior of all three is already verified by -`apps/macos/scripts/resilience_drill.py`; this pass tests the human + docs loop.) - ---- - -## 5. Sign-off checklist - -- [x] Maintainer suites green (pytest, IrisTests, ResilienceUITests, ruff, swiftlint, swiftformat) -- [x] Security automated checks pass; deny-by-default auth scan added -- [ ] `codesign --verify --deep --strict` + `spctl --assess` on the signed DMG -- [ ] User journey walked on a clean account; screenshots archived -- [ ] Operator diagnosed all three faults from logs + runbook alone -- [ ] Final screenshot set of every surface/state archived in the repo - -**This step stays open until the four boxes above are checked against a signed -release build.** diff --git a/docs/desktop/resilience-audit.md b/docs/desktop/resilience-audit.md index ad2bbff..e912e73 100644 --- a/docs/desktop/resilience-audit.md +++ b/docs/desktop/resilience-audit.md @@ -1,4 +1,4 @@ -# Resilience Audit (Step 7.3) +# Resilience Audit A scripted fault-injection drill that converts the failure-mode table in [`architecture.md` §8](architecture.md) (F1–F10) into _verified_ behavior. Every diff --git a/pyproject.toml b/pyproject.toml index c03e4ce..151d093 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,22 @@ build-backend = "setuptools.build_meta" [project] name = "iris-agent" version = "0.1.0" -description = "A local Mac vision and control agent." +description = "A local-first voice and vision agent for macOS." readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } authors = [{ name = "Clinton Imaro" }] +keywords = ["macos", "agent", "voice-assistant", "computer-use", "accessibility", "mcp"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: MacOS X", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] dependencies = [ "google-cloud-vision>=3.11.0", "openai>=2.8.0", @@ -21,6 +32,11 @@ dependencies = [ "pyobjc-framework-Quartz>=12.0; sys_platform == 'darwin'", ] +[project.urls] +Homepage = "https://github.com/bethvourc/iris" +Documentation = "https://github.com/bethvourc/iris/tree/main/docs/desktop" +Issues = "https://github.com/bethvourc/iris/issues" + [project.optional-dependencies] # On-device wake word ("Hey Iris"): openWakeWord runs ONNX models locally, so # no idle audio leaves the machine. Install with `pip install iris-agent[wakeword]`. diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 297ede8..098d236 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -4,9 +4,9 @@ from iris.gateway import AUTH_EXEMPT_PATHS, GatewayService -# Every route the gateway dispatches (handle_get / handle_post / SSE). The -# readiness review (docs/desktop/readiness-review.md) requires auth on every -# non-exempt route; this list is the deny-by-default scan's source of truth. +# Every route the gateway dispatches (handle_get / handle_post / SSE). Auth is +# required on every non-exempt route (docs/desktop/architecture.md); this list +# is the deny-by-default scan's source of truth. # Adding a route without adding it here — or widening AUTH_EXEMPT_PATHS — fails # the scan, forcing a conscious decision. _PROTECTED_ROUTES = ( From 7743b84ea710948eb70a29c5c97143fb164d7204 Mon Sep 17 00:00:00 2001 From: Bethvour Date: Tue, 22 Sep 2026 14:09:18 +0100 Subject: [PATCH 3/3] Pin SwiftFormat in CI to the version the code is formatted with Homebrew now installs SwiftFormat 0.63.0, which adds default-on rules (wrapIfStatementBodies, wrapIfExpressionBodies) and flags 30 untouched files, so every PR failed the macOS lint step. Disabling the rules in .swiftformat isn't an option because older versions reject unknown rule names, which would break local linting. Install 0.61.1 from the GitHub release instead, verified by SHA-256. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76de1aa..fbfea29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,21 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install tools - run: brew install xcodegen swiftlint swiftformat + run: brew install xcodegen swiftlint + # Pinned: new SwiftFormat releases add default-on rules (0.63.0 added + # wrapIfStatementBodies), which would fail every PR on untouched code. + # Bump the version and the checksum together, then reformat in one PR. + - name: Install SwiftFormat (pinned) + env: + SWIFTFORMAT_VERSION: 0.61.1 + SWIFTFORMAT_SHA256: b990400779aceb7d7020796eb9ba814d4480543f671d38fc0ff48cb72f04c584 + run: | + set -euo pipefail + curl -fsSL -o "$RUNNER_TEMP/swiftformat.zip" \ + "https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat.zip" + echo "${SWIFTFORMAT_SHA256} $RUNNER_TEMP/swiftformat.zip" | shasum -a 256 -c - + unzip -q "$RUNNER_TEMP/swiftformat.zip" -d "$RUNNER_TEMP/swiftformat" + echo "$RUNNER_TEMP/swiftformat" >> "$GITHUB_PATH" - name: Generate project run: xcodegen generate working-directory: apps/macos @@ -44,7 +58,7 @@ jobs: run: swiftlint --strict working-directory: apps/macos - name: SwiftFormat (lint only) - run: swiftformat --lint . + run: swiftformat --version && swiftformat --lint . working-directory: apps/macos - name: Build and test run: >