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..fbfea29 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 @@ -27,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 @@ -35,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: > @@ -48,7 +71,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/.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/.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/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) 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 = (