diff --git a/AGENTS.md b/AGENTS.md index 213e557..6f87d28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Native macOS **menu-bar app** that detects video calls, records dual-track audio - Project is generated by **XcodeGen** from `project.yml` (`brew install xcodegen`). `*.xcodeproj` is **gitignored** — regenerate, don't edit. - Full Xcode lives at `/Applications/Xcode.app`, but `xcode-select` points at CommandLineTools → **set `DEVELOPER_DIR` for every `xcodebuild`**. - Bundle id `xyz.ten31.transcripts`; `DEVELOPMENT_TEAM` (Apple Team ID) is set in a **gitignored `Config/Signing.xcconfig`** (copy `Config/Signing.xcconfig.example` and set your team). Keep it stable — a constant signing identity is what preserves TCC grants across rebuilds. -- Backend: SparkControl gateway at `$SPARK_BACKEND_URL` (a private LAN `.local` host; self-signed cert, so TLS-skip is intentional). Resolution order: a value saved in **Settings → SparkControl backend** (UserDefaults) wins, else the `SPARK_BACKEND_URL` env var, else the placeholder default in `AppSettings.swift`. Diarization = Sortformer/TitaNet (**mono-only**, ~4 speakers/chunk); LLM = Qwen3 via OpenAI-compatible `/v1/chat/completions`; audio via `/api/audio/label-merge`. +- Backend: SparkControl gateway at `$SPARK_BACKEND_URL` (a private LAN backend — IP or `.local` host; Start9 self-signed cert. Install the StartOS Root CA in the System keychain so normal TLS validation succeeds; skip-TLS is an opt-in, **host-scoped** escape hatch, **off by default** — see `InsecureTrustDelegate`). Resolution order: a value saved in **Settings → SparkControl backend** (UserDefaults) wins, else the `SPARK_BACKEND_URL` env var, else the placeholder default in `AppSettings.swift`. Diarization = Sortformer/TitaNet (**mono-only**, ~4 speakers/chunk); LLM = Qwen3 via OpenAI-compatible `/v1/chat/completions`; audio via `/api/audio/label-merge`. ## Commands First time on a machine — create the local signing config (else `xcodegen generate`/signing won't find a team): @@ -82,13 +82,14 @@ open /Applications/Ten31Transcripts.app - Never commit to `main` or force-push a shared branch; branch first and ask. ## Current state -Present tense; overwritten each session. 69 tests pass; `/Applications/Ten31Transcripts.app` matches HEAD and runs; working tree clean and pushed to `origin`/`main`. A full independent evaluation ran 2026-06-13 → `EVALUATION.md` (committed at repo root; overwritten + re-committed each run for a reviewable diff); its findings are triaged into the lists below. +Present tense; overwritten each session. 73 tests pass; `/Applications/Ten31Transcripts.app` matches HEAD and runs; working tree clean and pushed to `origin`/`main`. A full independent evaluation ran 2026-06-13 → `EVALUATION.md` (committed at repo root; overwritten + re-committed each run for a reviewable diff); its findings are triaged into the lists below. The eval's P1 (TLS) is now **fixed** and verified against the live backend. - **Working:** call detection (Meet/Zoom/Teams/Signal), dual-track capture, dual-channel + chunked backend hand-off, speaker reconciliation, recap (`transcript.md` + recap-relay-styled `recap.html`), speaker editor, configurable chunk length, standalone Settings window. - **In progress:** the Meet visual fix (reject solid camera-off tiles) is unverified end-to-end — no clean run exists yet; the saved Meet session's `visual_timeline.json` predates the fix. -- **Work queue (P1 — do first):** the TLS-trust override is global and on by default — it returns `URLCredential(trust:)` for *any* host (`InsecureTrustDelegate.swift:22`; default-on at `AppSettings.swift:109`), so the full mic+system audio, visual timeline, and voiceprint upload is MITM-able by anyone on the LAN. Scope the override to the configured backend host and pin the Start9 root CA (or the leaf SPKI hash); default skip-TLS to off. This gates trusting any later backend-integration test. +- **Done this session (was eval P1):** TLS validation is now **on by default** and the skip-TLS escape hatch is **scoped to the configured host** (`InsecureTrustDelegate.allowsTrustOverride`, covered by `InsecureTrustDelegateTests`). Supported path = the StartOS Root CA trusted in the System keychain; verified `URLSession` default validation returns 200 against both `192.0.2.1` and the `192.0.2.2` fallback. +- **Work queue (next up):** wire the backend URL + primary→fallback into config. Today it's a single `backendBaseURL` with no fallback logic, and on this Mac no value is saved (so it resolves to the `your-spark-backend.local` placeholder); the real setup is primary `https://192.0.2.1:62419` → fallback `https://192.0.2.2:62419`. - **Known debt (P2 — fix before wider use):** - `RecapAnalyzer.mmss()` fatally crashes on NaN/∞ (reproduced 2×); a malformed/MITM'd backend `duration` (e.g. `1e400` → `Double.infinity`) aborts the app at recap-render time — add a finite-guard fallback (`RecapAnalyzer.swift:137`). - - README is stale by six phases — still says "Phase 0 (scaffold) / no audio capture, detection, or backend hand-off yet" for a shipped Phase-6 app; same lie in source comment `AppSettings.swift:7`. Rewrite both to match reality. + - README is stale by six phases — still says "Phase 0 (scaffold) / no audio capture, detection, or backend hand-off yet" for a shipped Phase-6 app; same lie in source comment `AppSettings.swift:7`; and `README.md:49` still calls skip-TLS "on by default" (now off). Rewrite to match reality. - `SessionController` (670 lines, the most concurrency-dense file) has zero unit tests — cover `pendingAutoStop` (auto-start-then-immediate-call-end) and the visual-adoption generation guard before any refactor. - **Deferred (P3 — later decision or bulk cleanup; full evidence in `EVALUATION.md`):** `docs/` specs drifted from the dual-channel API + recap phase; `docs/01` §7 lists already-resolved open items; `docs/02` §2.10 claims MenuBarUI features that don't exist; AGENTS.md Layout listings under `Audio/`/`Detection/` are incomplete; the `manifest.json` sha256 contract is specced but never written; env-var precedence footgun (saved URL shadows `SPARK_BACKEND_URL`); `SessionController` owns three jobs (extract the open-panel UI); unused `NSAppleEventsUsageDescription`; unauthenticated LAN backend (consider a shared bearer token). - **Known bugs:** Meet speaking-detection is sparse (faint blue border); the mic channel emits some sub-second junk "self" fragments; the same person on desktop-mic vs phone-speakerphone does not unify by voiceprint. diff --git a/Ten31Transcripts/Backend/GatewayLLMClient.swift b/Ten31Transcripts/Backend/GatewayLLMClient.swift index 9917d8f..b8f5d3e 100644 --- a/Ten31Transcripts/Backend/GatewayLLMClient.swift +++ b/Ten31Transcripts/Backend/GatewayLLMClient.swift @@ -33,7 +33,9 @@ final class GatewayLLMClient { config.timeoutIntervalForRequest = 600 config.timeoutIntervalForResource = 900 config.waitsForConnectivity = false - let delegate: URLSessionDelegate? = skipTLS ? InsecureTrustDelegate() : nil + let delegate: URLSessionDelegate? = skipTLS + ? InsecureTrustDelegate(allowedHost: URL(string: self.baseURL)?.host) + : nil self.urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) } diff --git a/Ten31Transcripts/Backend/InsecureTrustDelegate.swift b/Ten31Transcripts/Backend/InsecureTrustDelegate.swift index 84681ba..6020203 100644 --- a/Ten31Transcripts/Backend/InsecureTrustDelegate.swift +++ b/Ten31Transcripts/Backend/InsecureTrustDelegate.swift @@ -1,19 +1,42 @@ import Foundation -/// URLSession delegate that trusts the server certificate without validation. +/// URLSession delegate that bypasses certificate validation for **one host only** +/// — the configured SparkControl backend. /// -/// SparkControl sits behind a Start9 self-signed Root CA on the LAN, so default -/// trust evaluation rejects it. This delegate is used **only** when the -/// "Skip TLS verification" setting is on. It trusts any server certificate — -/// acceptable for a personal tool on a trusted local network and nothing else. +/// SparkControl sits behind a Start9 self-signed Root CA on the LAN. The supported +/// path is to install that CA in the System keychain; default trust evaluation then +/// succeeds and this delegate is never used. It exists only as an opt-in escape +/// hatch (the "Skip TLS verification" setting, off by default) for a machine where +/// the CA isn't installed. Even then it trusts a certificate only when the challenge +/// host equals `allowedHost` — a server-trust challenge from any other host falls +/// back to default validation, so the bypass can never become "trust any server". final class InsecureTrustDelegate: NSObject, URLSessionDelegate { + /// The single host the bypass is scoped to (the configured backend host). When + /// nil — only reachable via a malformed base URL — the gate never fires and every + /// challenge falls back to default validation: the safe degenerate case. + private let allowedHost: String? + + init(allowedHost: String?) { + self.allowedHost = allowedHost + } + + /// The security gate: the trust override may fire only for a server-trust + /// challenge whose host matches `allowedHost`. Pure and synchronous so the + /// host-scoping can be unit-tested without fabricating a `SecTrust`; the + /// credential itself is built only when this is true *and* a serverTrust exists. + func allowsTrustOverride(for space: URLProtectionSpace) -> Bool { + guard let allowedHost else { return false } + return space.authenticationMethod == NSURLAuthenticationMethodServerTrust + && space.host == allowedHost + } + func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { guard - challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + allowsTrustOverride(for: challenge.protectionSpace), let serverTrust = challenge.protectionSpace.serverTrust else { completionHandler(.performDefaultHandling, nil) diff --git a/Ten31Transcripts/Backend/SparkControlClient.swift b/Ten31Transcripts/Backend/SparkControlClient.swift index 0d4f402..1fe1b54 100644 --- a/Ten31Transcripts/Backend/SparkControlClient.swift +++ b/Ten31Transcripts/Backend/SparkControlClient.swift @@ -82,7 +82,9 @@ final class SparkControlClient { config.timeoutIntervalForRequest = 600 // diarization can take up to ~600s config.timeoutIntervalForResource = 900 config.waitsForConnectivity = false - let delegate: URLSessionDelegate? = skipTLS ? InsecureTrustDelegate() : nil + let delegate: URLSessionDelegate? = skipTLS + ? InsecureTrustDelegate(allowedHost: URL(string: self.baseURL)?.host) + : nil self.urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) } diff --git a/Ten31Transcripts/Backend/SparkControlHealth.swift b/Ten31Transcripts/Backend/SparkControlHealth.swift index 06fc666..491915d 100644 --- a/Ten31Transcripts/Backend/SparkControlHealth.swift +++ b/Ten31Transcripts/Backend/SparkControlHealth.swift @@ -32,7 +32,9 @@ final class SparkControlHealth: ObservableObject { config.timeoutIntervalForRequest = 8 config.waitsForConnectivity = false - let delegate: URLSessionDelegate? = skipTLS ? InsecureTrustDelegate() : nil + let delegate: URLSessionDelegate? = skipTLS + ? InsecureTrustDelegate(allowedHost: url.host) + : nil let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) defer { session.finishTasksAndInvalidate() } diff --git a/Ten31Transcripts/Settings/AppSettings.swift b/Ten31Transcripts/Settings/AppSettings.swift index 5b2f452..037513d 100644 --- a/Ten31Transcripts/Settings/AppSettings.swift +++ b/Ten31Transcripts/Settings/AppSettings.swift @@ -106,7 +106,10 @@ final class AppSettings: ObservableObject { ?? ProcessInfo.processInfo.environment["SPARK_BACKEND_URL"] ?? Self.defaultBackendURL - self.skipTLSVerification = defaults.object(forKey: Keys.skipTLS) as? Bool ?? true + // Off by default: install the Start9 Root CA in the System keychain and the + // backend's cert validates normally. The bypass is an opt-in escape hatch and, + // when on, is scoped to the configured host (see `InsecureTrustDelegate`). + self.skipTLSVerification = defaults.object(forKey: Keys.skipTLS) as? Bool ?? false self.outputFolderPath = defaults.string(forKey: Keys.outputFolder) ?? "~/Ten31Transcripts" diff --git a/Ten31TranscriptsTests/InsecureTrustDelegateTests.swift b/Ten31TranscriptsTests/InsecureTrustDelegateTests.swift new file mode 100644 index 0000000..ed258fd --- /dev/null +++ b/Ten31TranscriptsTests/InsecureTrustDelegateTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import Ten31Transcripts + +/// The TLS bypass is an opt-in escape hatch scoped to the configured backend host. +/// These cover the security gate (`allowsTrustOverride`) so a regression can't widen +/// it back to "trust any server". The gate is pure, so no network or SecTrust needed. +final class InsecureTrustDelegateTests: XCTestCase { + private func space(host: String, + method: String = NSURLAuthenticationMethodServerTrust) -> URLProtectionSpace { + URLProtectionSpace(host: host, port: 62419, protocol: "https", + realm: nil, authenticationMethod: method) + } + + func testFiresForMatchingHost() { + let d = InsecureTrustDelegate(allowedHost: "192.0.2.1") + XCTAssertTrue(d.allowsTrustOverride(for: space(host: "192.0.2.1"))) + } + + func testRejectsMismatchedHost() { + let d = InsecureTrustDelegate(allowedHost: "192.0.2.1") + XCTAssertFalse(d.allowsTrustOverride(for: space(host: "evil.example.com"))) + } + + func testNilAllowedHostNeverFires() { + let d = InsecureTrustDelegate(allowedHost: nil) + XCTAssertFalse(d.allowsTrustOverride(for: space(host: "192.0.2.1"))) + } + + func testOnlyServerTrustMethodFires() { + // Matching host but a non-server-trust challenge (e.g. HTTP Basic) must not override. + let d = InsecureTrustDelegate(allowedHost: "192.0.2.1") + XCTAssertFalse(d.allowsTrustOverride( + for: space(host: "192.0.2.1", method: NSURLAuthenticationMethodHTTPBasic))) + } +}