From 588072825cfde8608b051852ade2097d91775a87 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Mon, 28 Sep 2026 20:52:47 -0500 Subject: [PATCH 1/3] Writing: keep secrets out of Personal History and the next-word predictor Personal History used to get every raw keyboard batch as it arrived, so a password typed at a sudo prompt in Terminal, an OTP or a card number went into the encrypted log, and a password with letters in it could be learned and served as a ghost suggestion. Now it's reached only through Save my writing's entry composer. An entry's events go to the controller once the entry closes, and only if WritingSecretScrubber redacts nothing from it, as saved or as the keyboard sent it (a secret typed and then Backspaced out is still in the events). The recorder hands cleared events to PersonalHistoryRelay, which delivers them in order in batches the controller takes. WritingHistoryIngest no longer calls the controller directly. The controller and predictor are unchanged; the Tilde deviation is in the port ledger. --- .../WritingDayFileRecorder.swift | 107 ++++++++++++++++-- .../SaveMyWriting/WritingEntryComposer.swift | 71 +++++++++++- Sources/Writing/WritingController.swift | 15 ++- Sources/Writing/WritingDayFileWriter.swift | 12 +- .../Runtime/WritingAppScopeTests.swift | 10 +- docs/writing-plan.md | 1 + docs/writing-port-ledger.md | 4 +- 7 files changed, 190 insertions(+), 30 deletions(-) diff --git a/Sources/TranscriptedWriting/Runtime/SaveMyWriting/WritingDayFileRecorder.swift b/Sources/TranscriptedWriting/Runtime/SaveMyWriting/WritingDayFileRecorder.swift index a98cfb3e7..76cddbda3 100644 --- a/Sources/TranscriptedWriting/Runtime/SaveMyWriting/WritingDayFileRecorder.swift +++ b/Sources/TranscriptedWriting/Runtime/SaveMyWriting/WritingDayFileRecorder.swift @@ -6,9 +6,12 @@ import Foundation /// Save my writing inside the app: keyboard events in, Markdown day files /// out. Gates every batch (Save my writing on, the current history and /// consent, the app scope), composes entries, and appends each closed entry -/// to `/Writing_.md`. One serial queue owns the composer -/// and every write, so `flush()` at quit and `deleteAll()` never race an -/// append. Not part of Tilde. +/// to `/Writing_.md`. It's also the only way into +/// Personal History: the events of each entry that closed with nothing to +/// scrub go to `releaseToPersonalHistory`, and an entry with a secret in it +/// never does. One serial queue owns the composer and every write, so +/// `flush()` at quit and `deleteAll()` never race an append. Not part of +/// Tilde. final class WritingDayFileRecorder: @unchecked Sendable { /// Read fresh for every batch and every write, like Tilde's settings. struct Gate: Equatable, Sendable { @@ -76,6 +79,7 @@ final class WritingDayFileRecorder: @unchecked Sendable { private let didWrite: @Sendable (URL) -> Void private let writeFailed: @Sendable () -> Void private let writeProblemStarted: @Sendable (WritingDayFileStore.StoreError) -> Void + private let releaseToPersonalHistory: @Sendable ([PersonalHistoryEvent]) -> Void private var composer: WritingEntryComposer private var rememberedEventIDs: Set = [] private var rememberedEventOrder: [String] = [] @@ -94,6 +98,8 @@ final class WritingDayFileRecorder: @unchecked Sendable { /// `writeFailed` runs on every failed append. `writeProblemStarted` runs /// once per problem: on the first failure after a success (or before any /// write), not again until a write succeeds and another fails. + /// `releaseToPersonalHistory` gets cleared events in keyboard order, on + /// the recorder's queue; it must hand them off, not wait on them. init( directory: @escaping @Sendable () -> URL, gate: @escaping @Sendable () -> Gate, @@ -106,7 +112,8 @@ final class WritingDayFileRecorder: @unchecked Sendable { }, didWrite: @escaping @Sendable (URL) -> Void = { _ in }, writeFailed: @escaping @Sendable () -> Void = {}, - writeProblemStarted: @escaping @Sendable (WritingDayFileStore.StoreError) -> Void = { _ in } + writeProblemStarted: @escaping @Sendable (WritingDayFileStore.StoreError) -> Void = { _ in }, + releaseToPersonalHistory: @escaping @Sendable ([PersonalHistoryEvent]) -> Void = { _ in } ) { self.directory = directory self.gate = gate @@ -117,6 +124,7 @@ final class WritingDayFileRecorder: @unchecked Sendable { self.didWrite = didWrite self.writeFailed = writeFailed self.writeProblemStarted = writeProblemStarted + self.releaseToPersonalHistory = releaseToPersonalHistory composer = WritingEntryComposer { milliseconds in WritingDayFileFormatter.entryID( forMilliseconds: milliseconds, @@ -139,12 +147,18 @@ final class WritingDayFileRecorder: @unchecked Sendable { /// Writes the open entry once it has been idle for 2 minutes. func closeIdleEntries() { - queue.sync { write(composer.closeIdle(now: now())) } + queue.sync { + write(composer.closeIdle(now: now())) + releaseClearedHistory() + } } /// Writes whatever is open. The app calls it at quit. func flush() { - queue.sync { write(composer.closeAll()) } + queue.sync { + write(composer.closeAll()) + releaseClearedHistory() + } } /// Delete all writing: drops the open entry and anything unwritten, then @@ -202,6 +216,25 @@ final class WritingDayFileRecorder: @unchecked Sendable { } guard !admitted.isEmpty else { return } write(composer.ingest(admitted, receivedAt: now())) + releaseClearedHistory() + } + + /// Hands Personal History the events of entries that closed clean, + /// re-checked against the gate like a write: turning Save my writing off + /// or narrowing the scope drops them here too. + private func releaseClearedHistory() { + let cleared = composer.takeClearedHistory() + guard !cleared.isEmpty else { return } + let gate = gate() + let admitted = cleared.filter { + gate.admits( + appBundleIdentifier: $0.appBundleIdentifier, + historyIdentifier: $0.historyIdentifier, + consentIdentifier: $0.consentIdentifier + ) + } + guard !admitted.isEmpty else { return } + releaseToPersonalHistory(admitted) } private func remember(_ eventID: String) -> Bool { @@ -272,15 +305,19 @@ final class WritingDayFileRecorder: @unchecked Sendable { } } -/// What the socket server ingests: every Personal History batch goes to -/// Tilde's controller (the encrypted log and the predictor) and to the day -/// files. The app scope is re-checked here the way Tilde's app re-checks its -/// exclusions; the keyboard applied it already. Not part of Tilde. +/// What the socket server ingests. Every batch goes to the day files' +/// recorder, which is also the way into Tilde's controller (the encrypted +/// log and the predictor): an entry reaches it once it has closed with +/// nothing to scrub (`WritingDayFileRecorder`, `PersonalHistoryRelay`). +/// In Tilde a batch went to the controller as it arrived. The app scope is +/// re-checked here the way Tilde's app re-checks its exclusions; the +/// keyboard applied it already. Not part of Tilde. struct WritingHistoryIngest: PersonalHistoryIngesting { - let personalHistory: any PersonalHistoryIngesting let dayFiles: WritingDayFileRecorder let appScope: @Sendable () -> WritingAppScope + /// `true` once the batch is composed: the keyboard doesn't resend it. + /// Personal History takes it later, when its entry closes. func ingest(_ events: [PersonalHistoryEvent]) async -> Bool { guard PersonalHistoryEvent.validBatch(events) else { return false } let scope = appScope() @@ -288,6 +325,52 @@ struct WritingHistoryIngest: PersonalHistoryIngesting { // Acknowledged and never kept, like an excluded app in Tilde. guard !inScope.isEmpty else { return true } await dayFiles.ingest(inScope) - return await personalHistory.ingest(inScope) + return true + } +} + +/// Hands the events Save my writing cleared to Personal History, in the +/// order they cleared, in batches the controller takes. A batch the +/// controller refuses (storage down) is not retried: the controller's +/// storage health already shows it isn't saving. Not part of Tilde. +final class PersonalHistoryRelay: @unchecked Sendable { + private let personalHistory: any PersonalHistoryIngesting + private let lock = NSLock() + private var tail = OrderedAsyncTaskTail() + + init(personalHistory: any PersonalHistoryIngesting) { + self.personalHistory = personalHistory + } + + /// Returns at once; the batches go out in call order. + func send(_ events: [PersonalHistoryEvent]) { + let batches = Self.batches(events) + guard !batches.isEmpty else { return } + let personalHistory = personalHistory + lock.withLock { + _ = tail.enqueue { + for batch in batches { _ = await personalHistory.ingest(batch) } + } + } + } + + /// Waits until everything sent so far has been handed over. + func drain() async { + let marker = lock.withLock { tail.enqueue {} } + _ = await marker.result + } + + /// Consecutive batches the controller accepts, in order. + private static func batches(_ events: [PersonalHistoryEvent]) -> [[PersonalHistoryEvent]] { + var batches: [[PersonalHistoryEvent]] = [] + var remaining = events[...] + while !remaining.isEmpty { + let batch = PersonalHistoryEvent.boundedBatchPrefix(Array(remaining)) + // Every event fits a batch on its own; this only guards the loop. + guard !batch.isEmpty else { break } + batches.append(batch) + remaining = remaining.dropFirst(batch.count) + } + return batches } } diff --git a/Sources/TranscriptedWriting/Runtime/SaveMyWriting/WritingEntryComposer.swift b/Sources/TranscriptedWriting/Runtime/SaveMyWriting/WritingEntryComposer.swift index 039f85e01..5d22d1026 100644 --- a/Sources/TranscriptedWriting/Runtime/SaveMyWriting/WritingEntryComposer.swift +++ b/Sources/TranscriptedWriting/Runtime/SaveMyWriting/WritingEntryComposer.swift @@ -16,8 +16,13 @@ import Foundation /// no letters, like a card or OTP box) don't count toward the 3 words, so a /// card typed over four boxes stays one entry the scrubber can see whole. Every closed entry goes through /// `WritingSecretScrubber` before it's returned; an entry that was nothing -/// but a secret isn't returned at all. Pure: the caller passes the clock. -/// Not part of Tilde. +/// but a secret isn't returned at all. +/// +/// Personal History (the encrypted log and the next-word predictor) gets the +/// keyboard's events only through here, once their entry has closed: +/// `takeClearedHistory()`. An entry the scrubber redacts anything from, as +/// saved or as typed before Backspace, gives Personal History nothing. Pure: +/// the caller passes the clock. Not part of Tilde. struct WritingEntryComposer { static let idleGapMilliseconds: Int64 = 120_000 static let minimumCharacters = 2 @@ -51,6 +56,9 @@ struct WritingEntryComposer { let firstTimestampMilliseconds: Int64 var lastActivityMilliseconds: Int64 var pieces: [Piece] = [] + /// The typed and accepted events as the keyboard sent them, for + /// Personal History. Never deletions: Personal History doesn't take them. + var historyEvents: [PersonalHistoryEvent] = [] var text: String { pieces.map(\.text).joined() } @@ -103,6 +111,7 @@ struct WritingEntryComposer { private let makeEntryID: @Sendable (Int64) -> String private var open: OpenEntry? private var recent: [ContextKey: RecentLines] = [:] + private var clearedHistory: [PersonalHistoryEvent] = [] /// `makeEntryID` gets the first keystroke's time in milliseconds. init(makeEntryID: @escaping @Sendable (Int64) -> String) { @@ -111,6 +120,14 @@ struct WritingEntryComposer { var hasOpenEntry: Bool { open != nil } + /// The Personal History events of every entry closed since the last + /// call, in keyboard order, and forgets them. Entries too short to save + /// still count; an entry that held a secret adds nothing. + mutating func takeClearedHistory() -> [PersonalHistoryEvent] { + defer { clearedHistory.removeAll() } + return clearedHistory + } + /// Takes one batch in keyboard order. `receivedAt` counts as activity /// for the entry the batch ends in: the keyboard sends a batch shortly /// after the last key in it, while an event's own timestamp is its first @@ -164,9 +181,11 @@ struct WritingEntryComposer { } /// Drops the open entry unsaved: Save my writing went off, or delete all. + /// Personal History gets none of it either. mutating func discardOpenEntry() { open = nil recent.removeAll() + clearedHistory.removeAll() } private mutating func closeOpen() -> Entry? { @@ -182,13 +201,15 @@ struct WritingEntryComposer { ), for: Self.contextKey(entry) ) - guard typed.count >= Self.minimumCharacters else { return nil } let scrubbed = WritingSecretScrubber.scrub( typed, appBundleIdentifier: entry.appBundleIdentifier, precedingLines: context ) - guard !scrubbed.isOnlyRedactions else { return nil } + if !Self.holdsSecret(entry, typed: typed, scrubbed: scrubbed, context: context) { + clearedHistory += entry.historyEvents + } + guard typed.count >= Self.minimumCharacters, !scrubbed.isOnlyRedactions else { return nil } let text = scrubbed.clean.trimmingCharacters(in: .whitespacesAndNewlines) guard text.count >= Self.minimumCharacters else { return nil } let wordCount = Self.wordCount(text) @@ -208,6 +229,40 @@ struct WritingEntryComposer { ) } + /// Whether the scrubber redacts anything from the entry as saved, or from + /// it as the keyboard sent it. The sent text matters when Backspace took + /// a secret back out: the saved entry no longer has it, but Personal + /// History would store every event, the deleted text included. The + /// scrubber changes text only to redact, so any change counts. + private static func holdsSecret( + _ entry: OpenEntry, + typed: String, + scrubbed: WritingSecretScrubber.Result, + context: [String] + ) -> Bool { + guard scrubbed.clean == typed else { return true } + let sent = sentText(entry.historyEvents) + guard sent != typed else { return false } + return WritingSecretScrubber.scrub( + sent, + appBundleIdentifier: entry.appBundleIdentifier, + precedingLines: context + ).clean != sent + } + + /// The events' text the way Personal History keeps it: one line per + /// keyboard segment, and each Backspace there starts a new segment. + private static func sentText(_ events: [PersonalHistoryEvent]) -> String { + var text = "" + var session: String? + for event in events { + if let session, session != event.sessionIdentifier, !text.isEmpty { text += "\n" } + session = event.sessionIdentifier + text += event.text + } + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + /// The previous entry's tail when it was the same app and history and /// ended within the idle gap of this entry's first keystroke. private func precedingLines(for entry: OpenEntry) -> [String] { @@ -263,8 +318,12 @@ struct WritingEntryComposer { private static func apply(_ event: PersonalHistoryEvent, to entry: inout OpenEntry) { switch event.source { - case .typed: entry.append(event.text, accepted: false) - case .acceptedSuggestion: entry.append(event.text, accepted: true) + case .typed: + entry.append(event.text, accepted: false) + entry.historyEvents.append(event) + case .acceptedSuggestion: + entry.append(event.text, accepted: true) + entry.historyEvents.append(event) case .deletion: entry.deleteLast(event.deletedCharacters ?? 0) } } diff --git a/Sources/Writing/WritingController.swift b/Sources/Writing/WritingController.swift index ff6d507f4..c92ba9296 100644 --- a/Sources/Writing/WritingController.swift +++ b/Sources/Writing/WritingController.swift @@ -323,6 +323,11 @@ final class WritingController { port: TildeProductProfile.current.llamaServerPort, modelFileProvider: { models.manager.verifiedInstalledModelFile() } ) + let personalHistoryController = PersonalHistoryController( + store: EncryptedPersonalHistoryStore(), + settings: settings, + diagnostics: .shared + ) let runtime = Runtime( models: models, llamaServerHost: llamaServerHost, @@ -330,11 +335,7 @@ final class WritingController { baseURL: llamaServerHost.baseURL, accessKey: llamaServerHost.accessKey ), - personalHistoryController: PersonalHistoryController( - store: EncryptedPersonalHistoryStore(), - settings: settings, - diagnostics: .shared - ), + personalHistoryController: personalHistoryController, // Screen Memory serves Autocomplete only: with it off, nothing // on screen is read, even with Screen Recording granted. screenCaptureService: ScreenCaptureService( @@ -346,9 +347,12 @@ final class WritingController { }, excludedApps: { Self.settings().personalHistoryExcludedApps } ), + // Personal History takes only entries Save my writing cleared of + // secrets, so it's reached through the day files, not the socket. dayFiles: WritingDayFileWriter( directory: writingDirectory, preferences: { Self.preferences() }, + personalHistory: personalHistoryController, problemStarted: { [weak self] error in self?.log("WRITING | save my writing: day file write failed (\(error))") } @@ -840,7 +844,6 @@ final class WritingController { runtime: runtime.llamaServerHost, personalHistory: WritingPausableIngest( base: WritingHistoryIngest( - personalHistory: runtime.personalHistoryController, dayFiles: runtime.dayFiles.recorder, appScope: { Self.preferences().appScope } ), diff --git a/Sources/Writing/WritingDayFileWriter.swift b/Sources/Writing/WritingDayFileWriter.swift index ff084f000..c830a8798 100644 --- a/Sources/Writing/WritingDayFileWriter.swift +++ b/Sources/Writing/WritingDayFileWriter.swift @@ -9,6 +9,8 @@ extension Notification.Name { /// Save my writing's host side: builds the `WritingDayFileRecorder` against /// the capture library, closes idle entries on a timer, and flushes at quit. +/// Entries that close with nothing to scrub go on to Personal History through +/// `personalHistory`. /// The recorder, composer and formatter live in `TranscriptedWriting/Runtime` /// so they're tested under `swift test`; this owns only what needs AppKit or /// the app's paths. @@ -25,6 +27,8 @@ final class WritingDayFileWriter { } let recorder: WritingDayFileRecorder + /// Where cleared entries go to Personal History. + let personalHistory: PersonalHistoryRelay private var idleTimer: Timer? /// Cleared at `stop()` so a rescrub still running stops between files. private let rescrubAllowed = RescrubFlag() @@ -34,9 +38,12 @@ final class WritingDayFileWriter { init( directory: @escaping @Sendable () -> URL, preferences: @escaping @Sendable () -> WritingPreferences, + personalHistory: any PersonalHistoryIngesting, problemStarted: @escaping @MainActor @Sendable (WritingDayFileStore.StoreError) -> Void = { _ in } ) { let names = WritingAppDisplayNames() + let relay = PersonalHistoryRelay(personalHistory: personalHistory) + self.personalHistory = relay recorder = WritingDayFileRecorder( directory: directory, gate: { WritingDayFileRecorder.Gate(preferences: preferences()) }, @@ -51,7 +58,8 @@ final class WritingDayFileWriter { }, writeProblemStarted: { error in Task { @MainActor in problemStarted(error) } - } + }, + releaseToPersonalHistory: { relay.send($0) } ) } @@ -93,6 +101,8 @@ final class WritingDayFileWriter { } /// The final flush. Synchronous on purpose: it runs from the app's quit. + /// The open entry's day-file write finishes here; its Personal History + /// batch is handed off and lands only if the app lives long enough. func stop() { rescrubAllowed.set(false) idleTimer?.invalidate() diff --git a/Tests/TranscriptedWritingTests/Runtime/WritingAppScopeTests.swift b/Tests/TranscriptedWritingTests/Runtime/WritingAppScopeTests.swift index 68d36a173..1bec0d419 100644 --- a/Tests/TranscriptedWritingTests/Runtime/WritingAppScopeTests.swift +++ b/Tests/TranscriptedWritingTests/Runtime/WritingAppScopeTests.swift @@ -178,14 +178,15 @@ struct WritingAppScopeTests { .appendingPathComponent("transcripted-scope-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("writing", isDirectory: true) defer { try? FileManager.default.removeItem(at: directory.deletingLastPathComponent()) } + let controller = Controller() + let relay = PersonalHistoryRelay(personalHistory: controller) let recorder = WritingDayFileRecorder( directory: { directory }, gate: { .init(enabled: true, historyIdentifier: "history", consentIdentifier: "consent") }, - appName: { _ in "App" } + appName: { _ in "App" }, + releaseToPersonalHistory: { relay.send($0) } ) - let controller = Controller() let ingest = WritingHistoryIngest( - personalHistory: controller, dayFiles: recorder, appScope: { .picked([Self.slack]) } ) @@ -200,8 +201,9 @@ struct WritingAppScopeTests { text: "outside the scope" )! #expect(await ingest.ingest([mailEvent])) - #expect(await controller.batches.isEmpty) recorder.flush() + await relay.drain() + #expect(await controller.batches.isEmpty) #expect(!FileManager.default.fileExists(atPath: directory.path)) } diff --git a/docs/writing-plan.md b/docs/writing-plan.md index 36d61d58a..151d4e4fe 100644 --- a/docs/writing-plan.md +++ b/docs/writing-plan.md @@ -259,6 +259,7 @@ Source: Tilde `f36f6562`. "Same" means a straight port, with only identities and | --- | --- | | Captures typed and accepted text with app, time and segment. Segments break on caret or app change, deletion, modifiers and secure input | Same, gated by **Save my writing** and the app scope | | Built-in password-manager exclusions plus the user's list | Same. **New:** an allowlist mode for "Only apps I pick" | +| Secrets typed where secure input is off (sudo prompts, codes, cards) reach the log and the predictor | **Changed:** Personal History takes an entry only after Save my writing's scrubber finds nothing in it (`docs/writing-port-ledger.md`, "Secrets in Personal History") | | Encrypted log and model with the key in Keychain | **Changed** per decision 6. See [Storage](#storage) | | Delete all | Same, and it also deletes the writing Markdown files | | Personal n-gram predictor, and its rules for replacing the model's ghost | Same logic. Its default is an open question | diff --git a/docs/writing-port-ledger.md b/docs/writing-port-ledger.md index 3933f8dd5..b9ee97591 100644 --- a/docs/writing-port-ledger.md +++ b/docs/writing-port-ledger.md @@ -119,7 +119,7 @@ python3 ~/tilde-port/parity-diff.py --ledger docs/writing-port-ledger.md --repo | `Sources/TildeApp/Mac/DiagnosticsLog.swift` | `Sources/TranscriptedWriting/Runtime/DiagnosticsLog.swift` | ported | 2 | Write under Transcripted's logs. Deviation: never writes under tests or with `TRANSCRIPTED_DISABLE_FILE_LOGGER=1`, same as `FileLogger`. | | `Sources/TildeApp/Mac/SecureLocalStorage.swift` | `Sources/TranscriptedWriting/Runtime/PersonalHistory/SecureLocalStorage.swift` | ported | 2 | Straight port; moved from phase 3. | | `Sources/TildeApp/PersonalHistory/PersonalBrainStatus.swift` | `—` | not-ported | 3 | Only used by a dev JSON flag. | -| `Sources/TildeApp/PersonalHistory/PersonalHistoryController.swift` | `Sources/TranscriptedWriting/Runtime/PersonalHistory/PersonalHistoryController.swift` | ported | 2 | Straight port; moved from phase 3. Phase 3 deviation: `ingest` drops `.deletion` events, so the encrypted log keeps Tilde's events only. Save my writing is its on switch; `WritingHistoryIngest` re-checks the app scope before a batch reaches it. | +| `Sources/TildeApp/PersonalHistory/PersonalHistoryController.swift` | `Sources/TranscriptedWriting/Runtime/PersonalHistory/PersonalHistoryController.swift` | ported | 2 | Straight port; moved from phase 3. Phase 3 deviation: `ingest` drops `.deletion` events, so the encrypted log keeps Tilde's events only. Save my writing is its on switch; `WritingHistoryIngest` re-checks the app scope before a batch reaches it. Batches reach it only once Save my writing has cleared their entry of secrets (see Deviations, "Secrets in Personal History"). | | `Sources/TildeApp/PersonalHistory/PersonalHistoryStore.swift` | `Sources/TranscriptedWriting/Runtime/PersonalHistory/PersonalHistoryStore.swift` | ported | 2 | Straight port; moved from phase 3 (Keychain service renamed). Predictor state stays app-owned; user-facing text goes to the Markdown day files (decision 6, `Runtime/SaveMyWriting/`). Delete all writing runs its `deleteAll` plus the day files. | | `Sources/TildeApp/PersonalHistory/ReplayEvalCommand.swift` | `—` | not-ported | 3 | Dev-only. | | `Sources/TildeApp/PersonalHistory/ReplayEvalOwnership.swift` | `—` | not-ported | 3 | Dev-only. | @@ -158,6 +158,7 @@ python3 ~/tilde-port/parity-diff.py --ledger docs/writing-port-ledger.md --repo - **History retries (phase 3 review):** Tilde retried a history batch the app refused every 5 s forever, so one event an older app can't read (a version-2 deletion) blocked all text behind it. Now the app answers a version-1 history batch that holds events of an unknown version with a terminal `unsupported` line naming those events' IDs (`GhostBrainRequest.unsupportedPersonalHistoryEventIDs`), and the keyboard drops only them and resends the rest. Any event the app refuses 12 times (`invalid_request`, `error`, an `unsupported` line naming nothing it sent) is dropped too. Not reaching the app (`unavailable`, `timeout`) still retries as in Tilde, and version-1 traffic is unchanged. - **Backspace counts (phase 3 review):** a deletion event's `deletedCharacters` counts UTF-16 units, not `Character`s. The keyboard reports the `utf16Length` of the character it inserted, and `WritingEntryComposer` removes that many units from the joined text. Counting `Character`s removed too much when a combining mark or joiner typed on its own merged into the character before it. Plain Backspace also passes the real Secure Event Input state to the capture permit (it was always `false`, safe only because of an earlier return). - **Secrets in day files:** Save my writing runs every entry through `WritingSecretScrubber` (Core, not in Tilde) before it's saved, and rescrubs day files already on disk once per rules version. It catches what secure input misses: terminal password prompts, password-shaped lines, one-time codes, split card numbers, labelled values and token formats. For that, `SecretRules.ScrubConfig` gained `scrubGenericTokens` and `scrubCardNumbers` (both default on, so Tilde's configs behave the same); the scrubber turns them off and runs stricter versions, because the loose ones took paths, SCREAMING_SNAKE names and UUIDs. Measured with `bash scripts/dev/measure-writing-scrubber.sh`. +- **Secrets in Personal History:** Tilde sent every keyboard batch straight to the encrypted log and the next-word predictor, guarded only by secure input and the password-manager list. So a password typed at a `sudo` prompt in Terminal, an OTP or a card number was stored, and a password with letters in it (`Tr0ub4dor&3` learns "ub" after "Tr") could be learned and served as a ghost suggestion. Now Personal History is reached only through Save my writing's `WritingEntryComposer`: an entry's events go to the controller once the entry closes (a new segment, 2 minutes idle, the app switching, quit), and only if `WritingSecretScrubber` redacts nothing from it, judged with the same per-app context lines the day files use, both as saved and as the keyboard sent it (a secret typed and then Backspaced out is still in the sent events). An entry with a secret gives Personal History nothing at all, including its ordinary words. Scrubbing the event stream itself doesn't work: a secret spans events and segments (a sudo password is the segment after the command, a card is four boxes), and what counts depends on text that hasn't arrived yet. Refusing at the predictor alone doesn't either: it learns letter runs (`Tr`, `ub`, `dor`), which the scrubber can't judge, and the log would still keep the raw text. What changes from Tilde: learning waits for the entry to close; the socket acknowledges a batch once it's composed, so a batch the controller then refuses (storage down) isn't retried, and the storage health line still says History isn't saving; an entry still open at quit is handed off but lands only if the app lives long enough; a retried batch reaches the log once (the recorder's event-ID check), not twice. `PersonalHistoryController` and the predictor are unchanged. Wiring: `WritingHistoryIngest` → `WritingDayFileRecorder` → `PersonalHistoryRelay` → the controller. - **Helper API key (security review):** Tilde ran `llama-server` open on loopback with CORS `*` and its web UI on. Loopback TCP isn't per-user, and `/completion` reports `timings.cache_n`, so any local process (another account too) or a web page could prefix-probe the last prompt, which holds typed text and Screen Memory text. Transcripted's host now issues a fresh 32-byte `SecRandomCopyBytes` key per launch (`LlamaServerAccessKey.swift`, memory only, never logged or saved), hands it to the helper as `LLAMA_API_KEY` in the environment so `ps` can't show it, adds `--no-webui`, and every app request (completions, scaffold prewarm, both health probes) sends `Authorization: Bearer `. The pinned build (llama.cpp `2115b73`) honors both; `/health` stays public there, which is fine. - **Socket path:** it's about 9 bytes longer than Tilde's. Usernames over about 28 characters would exceed `sun_path` (104 bytes), and Writing then stays off with a log line. Rare; revisit if it's ever reported. @@ -165,6 +166,7 @@ python3 ~/tilde-port/parity-diff.py --ledger docs/writing-port-ledger.md --repo - Check with real files: Return, Tab, arrows and shortcuts break segments, so each Slack message or Notes paragraph becomes its own writing entry. Decide at the phase 3 checkpoint whether consecutive segments in one app should merge. - Narrowing the app scope doesn't retroactively remove already-stored encrypted personal history; replay filters by the exclusion list only. +- Personal History written by development builds before the secrets fix may still hold secrets in the encrypted log and the saved trained model (no release shipped Writing before it: 1.1.66 has none). Delete all writing clears both; nothing rescrubs them in place, the way day files are rescrubbed. - Phase 2 cleanup: collapse `.preview9B` into a Qwen completion profile once nothing reads its preview identities. - Done: `llama-server` provenance is verified. A from-source rebuild of llama.cpp `2115b73` matches the pinned code hash exactly; see [llama-server-provenance.md](llama-server-provenance.md). The pin fails closed if a toolchain ever changes how `codesign --remove-signature` lays out bytes. From 911c7b4b41b08bf69be4de664f90574b63fb7a7e Mon Sep 17 00:00:00 2001 From: r3dbars Date: Mon, 28 Sep 2026 22:34:40 -0500 Subject: [PATCH 2/3] Test that secrets never reach Personal History or the predictor 21 behavior tests from the promises: a sudo password in Terminal is never stored or predicted (with a control proving the old path served it), ordinary writing is still learned, Backspaced secrets stay out, nothing is released while an entry is open, Save my writing off and delete all drop the open entry, a retried batch lands once, and long entries arrive whole and in order in batches the controller takes. --- .../Runtime/PersonalHistorySecretTests.swift | 557 ++++++++++++++++++ .../Runtime/WritingEntryComposerTests.swift | 94 +++ 2 files changed, 651 insertions(+) create mode 100644 Tests/TranscriptedWritingTests/Runtime/PersonalHistorySecretTests.swift diff --git a/Tests/TranscriptedWritingTests/Runtime/PersonalHistorySecretTests.swift b/Tests/TranscriptedWritingTests/Runtime/PersonalHistorySecretTests.swift new file mode 100644 index 000000000..d87949fdf --- /dev/null +++ b/Tests/TranscriptedWritingTests/Runtime/PersonalHistorySecretTests.swift @@ -0,0 +1,557 @@ +import Foundation +import Testing +@testable import TranscriptedWritingCore +@testable import TranscriptedWritingRuntime + +/// Keyboard batches go through Save my writing (`WritingHistoryIngest` -> +/// `WritingDayFileRecorder` -> `PersonalHistoryRelay`) before Personal History +/// (the encrypted log and the next-word predictor) sees them. These tests +/// drive that whole path the way the app wires it and check what the log +/// stores and what the predictor would serve. +@Suite("Personal History gets only closed, secret-free writing") +struct PersonalHistorySecretTests { + private static let start: Int64 = 1_790_350_931_387 + private static let terminal = "com.apple.Terminal" + private static let slack = "com.tinyspeck.slackmacgap" + /// Letter runs "Tr", "ub", "dor": a predictor that saw it twice serves + /// "dor" after "Tr ub". + private static let password = "Tr0ub4dor&3" + private static let passwordParts = ["Tr0", "0ub", "ub4", "4dor", "dor&", "&3"] + + // MARK: - Promise 1: a sudo password in Terminal is never stored or predicted + + @Test("A password typed after sudo in Terminal is never stored in the Personal History log and never predicted") + func terminalSudoPasswordNeverReachesHistory() async throws { + // Control: fed straight to a controller, this exact typing teaches the + // predictor the password. Without this the nil checks below prove nothing. + let control = History() + #expect(await settledStatus(control.controller).phase == .ready) + for event in Self.sudoThenPasswordTwice() { + #expect(await control.controller.ingest([event])) + } + #expect(await control.controller.personalNextWordPrediction( + afterTailWords: ["Tr", "ub"], appBundleIdentifier: Self.terminal + )?.word == "dor") + + let history = History() + #expect(await settledStatus(history.controller).phase == .ready) + let pipeline = try Pipeline(personalHistory: history.controller) + defer { pipeline.remove() } + + for event in Self.sudoThenPasswordTwice() { + #expect(await pipeline.send([event])) + } + pipeline.recorder.flush() + await pipeline.relay.drain() + + let stored = await history.store.events.map(\.text) + for part in Self.passwordParts { + #expect(!stored.contains { $0.contains(part) }, "stored text holds \(part)") + } + // The command itself isn't a secret and still reaches the log. + #expect(stored.filter { $0 == "sudo apt update" }.count == 2) + #expect(await history.controller.personalNextWordPrediction( + afterTailWords: ["Tr", "ub"], appBundleIdentifier: Self.terminal + ) == nil) + #expect(await history.controller.personalNextWordPrediction( + afterTailWords: ["Tr"], appBundleIdentifier: Self.terminal + ) == nil) + } + + @Test("A dictionary-word password typed after sudo in Terminal is never stored in the Personal History log") + func terminalSudoWordPasswordNeverStored() async throws { + let history = History() + #expect(await settledStatus(history.controller).phase == .ready) + let pipeline = try Pipeline(personalHistory: history.controller) + defer { pipeline.remove() } + + #expect(await pipeline.send([Self.typed("sudo apt update", session: "t-1", app: Self.terminal, at: Self.start)])) + #expect(await pipeline.send([Self.typed("sunshine", session: "t-2", app: Self.terminal, at: Self.start + 2_000)])) + #expect(await pipeline.send([Self.typed("ls -la", session: "t-3", app: Self.terminal, at: Self.start + 9_000)])) + pipeline.recorder.flush() + await pipeline.relay.drain() + + let stored = await history.store.events.map(\.text) + #expect(!stored.contains { $0.contains("sunshine") }) + #expect(stored.contains("sudo apt update")) + } + + // MARK: - Promise 2: ordinary writing is still stored and learned + + @Test("Ordinary writing still reaches the Personal History log and is learned") + func ordinaryWritingStillLearned() async throws { + let history = History() + #expect(await settledStatus(history.controller).phase == .ready) + let pipeline = try Pipeline(personalHistory: history.controller) + defer { pipeline.remove() } + + // The space after the last word is what makes it a finished word. + let phrase = "see you at the standup tomorrow " + #expect(await pipeline.send([Self.typed(phrase, session: "s-1", at: Self.start)])) + #expect(await pipeline.send([Self.typed(phrase, session: "s-2", at: Self.start + 30_000)])) + pipeline.recorder.flush() + await pipeline.relay.drain() + + #expect(await history.store.events.map(\.text) == [phrase, phrase]) + #expect(await history.controller.personalNextWordPrediction( + afterTailWords: ["the", "standup"], appBundleIdentifier: Self.slack + )?.word == "tomorrow") + } + + // MARK: - Promise 3: a secret removed with Backspace still never goes + + @Test("A secret typed and then removed with Backspace never reaches the Personal History log or the predictor") + func backspacedSecretNeverReachesHistory() async throws { + let history = History() + #expect(await settledStatus(history.controller).phase == .ready) + let pipeline = try Pipeline(personalHistory: history.controller) + defer { pipeline.remove() } + + // Typed into the wrong field twice, noticed, erased, then a message. + for (index, root) in ["wrong-a", "wrong-b"].enumerated() { + let at = Self.start + Int64(index) * 20_000 + let continued = PersonalHistorySegmentChain.continuation(of: root) + #expect(await pipeline.send([ + Self.typed(Self.password + " ", session: root, at: at), + try Self.deletion(Self.password.utf16.count + 1, session: continued, at: at + 1_000), + Self.typed("see you at lunch", session: continued, at: at + 2_000), + ])) + } + // A clean entry after them shows the path is live in this test. + #expect(await pipeline.send([Self.typed("sounds good to me", session: "clean", at: Self.start + 60_000)])) + pipeline.recorder.flush() + await pipeline.relay.drain() + + // What was left after Backspace was clean and is saved to the day file. + let saved = try pipeline.dayFileText() + #expect(saved.contains("see you at lunch")) + #expect(!saved.contains(Self.password)) + + let stored = await history.store.events + #expect(stored.map(\.text) == ["sounds good to me"]) + #expect(await history.controller.personalNextWordPrediction( + afterTailWords: ["Tr"], appBundleIdentifier: Self.slack + ) == nil) + #expect(await history.controller.personalNextWordPrediction( + afterTailWords: ["Tr", "ub"], appBundleIdentifier: Self.slack + ) == nil) + } + + @Test("A card number erased with Backspace keeps its whole entry out of Personal History while the clean text is saved") + func backspacedCardKeepsEntryOut() async throws { + let spy = HistorySpy() + let pipeline = try Pipeline(personalHistory: spy) + defer { pipeline.remove() } + + #expect(await pipeline.send([ + Self.typed("my card is ", session: "card", at: Self.start), + Self.typed("4111 ", session: "card", at: Self.start + 500), + Self.typed("1111 ", session: "card", at: Self.start + 1_000), + Self.typed("1111 ", session: "card", at: Self.start + 1_500), + Self.typed("1111", session: "card", at: Self.start + 2_000), + try Self.deletion(19, session: "card_1", at: Self.start + 3_000), + Self.typed("on file", session: "card_1", at: Self.start + 4_000), + ])) + pipeline.recorder.flush() + await pipeline.relay.drain() + + #expect(try pipeline.dayFileText().contains("my card is on file")) + #expect(await spy.events.isEmpty) + } + + // MARK: - Promise 4: nothing goes while the entry is open + + @Test("Nothing reaches Personal History while its entry is open; the next segment chain closes it and it arrives") + func openEntryHeldUntilNextChain() async throws { + let spy = HistorySpy() + let pipeline = try Pipeline(personalHistory: spy) + defer { pipeline.remove() } + + let first = [ + Self.typed("the launch moves ", session: "a", at: Self.start), + Self.typed("to Thursday", session: "a", at: Self.start + 1_000), + ] + #expect(await pipeline.send(first)) + await pipeline.relay.drain() + #expect(await spy.events.isEmpty) + + let second = Self.typed("Another message entirely", session: "b", at: Self.start + 5_000) + #expect(await pipeline.send([second])) + await pipeline.relay.drain() + #expect(await spy.events.map(\.id) == first.map(\.id)) + } + + @Test("An entry idle for 2 minutes reaches Personal History at the idle sweep, not before") + func idleEntryArrivesAtSweep() async throws { + let spy = HistorySpy() + let pipeline = try Pipeline(personalHistory: spy) + defer { pipeline.remove() } + + let event = Self.typed("checking in on the draft", session: "a", at: Self.start) + #expect(await pipeline.send([event])) + pipeline.clock.now = Self.start + 60_000 + pipeline.recorder.closeIdleEntries() + await pipeline.relay.drain() + #expect(await spy.events.isEmpty) + + pipeline.clock.now = Self.start + 200 + 120_001 + pipeline.recorder.closeIdleEntries() + await pipeline.relay.drain() + #expect(await spy.events.map(\.id) == [event.id]) + } + + @Test("The open entry reaches Personal History on flush at quit") + func flushReleasesOpenEntry() async throws { + let spy = HistorySpy() + let pipeline = try Pipeline(personalHistory: spy) + defer { pipeline.remove() } + + let event = Self.typed("last words before quitting", session: "a", at: Self.start) + #expect(await pipeline.send([event])) + await pipeline.relay.drain() + #expect(await spy.events.isEmpty) + pipeline.recorder.flush() + await pipeline.relay.drain() + #expect(await spy.events.map(\.id) == [event.id]) + } + + // MARK: - Promise 5: Save my writing off or Delete all drops the open entry + + @Test("Turning Save my writing off while an entry is open means it never reaches Personal History") + func turningOffDropsOpenEntry() async throws { + let spy = HistorySpy() + let pipeline = try Pipeline(personalHistory: spy) + defer { pipeline.remove() } + + #expect(await pipeline.send([Self.typed("typed before turning it off", session: "a", at: Self.start)])) + pipeline.gate.value.enabled = false + _ = await pipeline.send([Self.typed("typed while off", session: "b", at: Self.start + 5_000)]) + pipeline.gate.value.enabled = true + pipeline.recorder.flush() + await pipeline.relay.drain() + #expect(await spy.events.isEmpty) + } + + @Test("Turning Save my writing off and then quitting never sends the open entry to Personal History") + func turningOffThenFlushDropsOpenEntry() async throws { + let spy = HistorySpy() + let pipeline = try Pipeline(personalHistory: spy) + defer { pipeline.remove() } + + #expect(await pipeline.send([Self.typed("typed before turning it off", session: "a", at: Self.start)])) + pipeline.gate.value.enabled = false + pipeline.recorder.flush() + await pipeline.relay.drain() + #expect(await spy.events.isEmpty) + } + + @Test("Delete all while an entry is open means it never reaches Personal History") + func deleteAllDropsOpenEntry() async throws { + let spy = HistorySpy() + let pipeline = try Pipeline(personalHistory: spy) + defer { pipeline.remove() } + + #expect(await pipeline.send([Self.typed("typed before delete all", session: "a", at: Self.start)])) + #expect(pipeline.recorder.deleteAll()) + pipeline.recorder.flush() + await pipeline.relay.drain() + #expect(await spy.events.isEmpty) + } + + // MARK: - Promise 6: a retried batch goes once + + @Test("A batch the keyboard retries reaches Personal History once") + func retriedBatchArrivesOnce() async throws { + let spy = HistorySpy() + let pipeline = try Pipeline(personalHistory: spy) + defer { pipeline.remove() } + + let first = [ + Self.typed("first part of a note ", session: "a", at: Self.start), + Self.typed("and the rest of it", session: "a", at: Self.start + 500), + ] + #expect(await pipeline.send(first)) + #expect(await pipeline.send(first)) + let second = [Self.typed("a second note here", session: "b", at: Self.start + 5_000)] + #expect(await pipeline.send(second)) + // A retry that lands after its entry already closed. + #expect(await pipeline.send(first)) + pipeline.recorder.flush() + await pipeline.relay.drain() + + #expect(await spy.events.map(\.id) == (first + second).map(\.id)) + } + + // MARK: - Promise 7: a long entry all arrives, in order, in valid batches + + @Test("A long entry all reaches Personal History in keyboard order, in batches the controller accepts") + func longEntryArrivesInValidBatches() async throws { + let spy = HistorySpy() + let pipeline = try Pipeline(personalHistory: spy) + defer { pipeline.remove() } + + let typed = Self.longEntry() + #expect(typed.count > PersonalHistoryEvent.maximumBatchEvents) + #expect(typed.map(\.text.count).reduce(0, +) > PersonalHistoryEvent.maximumBatchTextCharacters) + for batch in Self.keyboardBatches(typed) { + #expect(await pipeline.send(batch)) + } + pipeline.recorder.flush() + await pipeline.relay.drain() + + let batches = await spy.batches + #expect(batches.count > 1) + #expect(batches.allSatisfy { PersonalHistoryEvent.validBatch($0) }) + #expect(batches.flatMap { $0 }.map(\.id) == typed.map(\.id)) + } + + @Test("A long entry is stored whole and in keyboard order by the real Personal History controller") + func longEntryStoredWhole() async throws { + let history = History() + #expect(await settledStatus(history.controller).phase == .ready) + let pipeline = try Pipeline(personalHistory: history.controller) + defer { pipeline.remove() } + + let typed = Self.longEntry() + for batch in Self.keyboardBatches(typed) { + #expect(await pipeline.send(batch)) + } + pipeline.recorder.flush() + await pipeline.relay.drain() + + #expect(await history.store.events.map(\.id) == typed.map(\.id)) + } + + // MARK: - Keyboard input + + /// `sudo apt update`, Return, the password about 2 s later, Return; again + /// two minutes later. (A command typed within a minute of the password + /// folds into the password's entry and is held back with it.) + private static func sudoThenPasswordTwice() -> [PersonalHistoryEvent] { + [ + typed("sudo apt update", session: "term-1", app: terminal, at: start), + typed(password, session: "term-2", app: terminal, at: start + 2_000), + typed("sudo apt update", session: "term-3", app: terminal, at: start + 120_000), + typed(password, session: "term-4", app: terminal, at: start + 122_000), + ] + } + + /// 26 events in one segment: some near the 512-character event limit. + private static func longEntry() -> [PersonalHistoryEvent] { + let long = String(repeating: "we should ship the report on friday ", count: 14) + let words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel"] + return (0..<26).map { index in + let text = index % 5 == 0 ? long : "\(words[index % words.count]) goes next " + return typed(text, session: "long", at: start + Int64(index) * 1_000) + } + } + + /// Splits events the way the keyboard does: the longest valid prefix each time. + private static func keyboardBatches(_ events: [PersonalHistoryEvent]) -> [[PersonalHistoryEvent]] { + var batches: [[PersonalHistoryEvent]] = [] + var current: [PersonalHistoryEvent] = [] + for event in events { + if !current.isEmpty && !PersonalHistoryEvent.validBatch(current + [event]) { + batches.append(current) + current = [] + } + current.append(event) + } + if !current.isEmpty { batches.append(current) } + return batches + } + + private static func typed( + _ text: String, + session: String, + app: String = slack, + at timestamp: Int64 + ) -> PersonalHistoryEvent { + PersonalHistoryEvent( + id: UUID().uuidString, + timestampMilliseconds: timestamp, + historyIdentifier: History.historyIdentifier, + consentIdentifier: History.consentIdentifier, + sessionIdentifier: session, + appBundleIdentifier: app, + source: .typed, + text: text + )! + } + + private static func deletion(_ count: Int, session: String, app: String = slack, at timestamp: Int64) throws -> PersonalHistoryEvent { + try #require(PersonalHistoryEvent( + deletionID: UUID().uuidString, + timestampMilliseconds: timestamp, + historyIdentifier: History.historyIdentifier, + consentIdentifier: History.consentIdentifier, + sessionIdentifier: session, + appBundleIdentifier: app, + deletedCharacters: count + )) + } + + private func settledStatus( + _ controller: PersonalHistoryController + ) async -> PersonalNextWordShadowStatus { + for _ in 0..<100 { + let status = await controller.nextWordStatus() + if status.phase == .ready || status.phase == .unavailable { return status } + try? await Task.sleep(for: .milliseconds(1)) + } + return await controller.nextWordStatus() + } + + // MARK: - The app's wiring, with a temp folder and a test clock + + /// `WritingDayFileWriter` + `WritingHistoryIngest` as the app builds them. + private final class Pipeline: @unchecked Sendable { + let root: URL + let writing: URL + let clock: Clock + let gate: GateBox + let relay: PersonalHistoryRelay + let recorder: WritingDayFileRecorder + let ingest: WritingHistoryIngest + + init(personalHistory: any PersonalHistoryIngesting) throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("transcripted-history-secret-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let writing = root.appendingPathComponent("writing", isDirectory: true) + self.writing = writing + let clock = Clock(PersonalHistorySecretTests.start) + self.clock = clock + let gate = GateBox(.init( + enabled: true, + historyIdentifier: History.historyIdentifier, + consentIdentifier: History.consentIdentifier + )) + self.gate = gate + let relay = PersonalHistoryRelay(personalHistory: personalHistory) + self.relay = relay + recorder = WritingDayFileRecorder( + directory: { writing }, + gate: { gate.value }, + appName: { _ in "App" }, + now: { Date(timeIntervalSince1970: TimeInterval(clock.now) / 1_000) }, + timeZone: { TimeZone(identifier: "America/Chicago")! }, + locale: Locale(identifier: "en_US"), + entryIDSuffix: { "0000abcd" }, + releaseToPersonalHistory: { relay.send($0) } + ) + ingest = WritingHistoryIngest(dayFiles: recorder, appScope: { .all }) + } + + /// One keyboard batch, arriving 200 ms after its last key. + func send(_ events: [PersonalHistoryEvent]) async -> Bool { + if let last = events.map(\.timestampMilliseconds).max() { + clock.now = last + 200 + } + return await ingest.ingest(events) + } + + func dayFileText() throws -> String { + let names = try FileManager.default.contentsOfDirectory(atPath: writing.path).sorted() + return try names.map { + try String(contentsOf: writing.appendingPathComponent($0), encoding: .utf8) + }.joined(separator: "\n") + } + + func remove() { try? FileManager.default.removeItem(at: root) } + } + + /// Records what reaches Personal History and accepts only what the real + /// controller would: a valid batch. + private actor HistorySpy: PersonalHistoryIngesting { + private(set) var batches: [[PersonalHistoryEvent]] = [] + var events: [PersonalHistoryEvent] { batches.flatMap { $0 } } + + func ingest(_ events: [PersonalHistoryEvent]) async -> Bool { + batches.append(events) + return PersonalHistoryEvent.validBatch(events) + } + } + + /// The real controller over an in-memory store, Personal History on. + private struct History { + static let historyIdentifier = "history" + static let consentIdentifier = "consent" + + let store = MemoryStore() + let controller: PersonalHistoryController + + init() { + let name = "transcripted.tests.history-secret.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + defaults.set(Self.historyIdentifier, forKey: PersonalHistorySettingsContract.historyIdentifierKey) + defaults.set(Self.consentIdentifier, forKey: PersonalHistorySettingsContract.consentIdentifierKey) + defaults.set("experiment", forKey: "PersonalNextWordExperimentIdentifier") + defaults.set(true, forKey: PersonalHistorySettingsContract.enabledKey) + defaults.set([String](), forKey: PersonalHistorySettingsContract.excludedAppsKey) + controller = PersonalHistoryController( + store: store, + settings: TildeSettings(keyboard: defaults), + modelPersistenceInterval: 0 + ) + } + } + + private actor MemoryStore: PersonalHistoryStore { + nonisolated let location = URL(fileURLWithPath: "/tmp/transcripted-history-secret-test") + private var records: [PersonalHistoryRecord] = [] + private var checkpoint: PersonalNextWordStoredCheckpoint? + private var trainedModel: PersonalNextWordStoredModel? + var events: [PersonalHistoryEvent] { records.flatMap(\.events) } + + @discardableResult + func append( + _ events: [PersonalHistoryEvent], + checkpoint: PersonalNextWordStoredCheckpoint? + ) async throws -> Int64 { + let sequence = Int64(records.count + 1) + records.append(PersonalHistoryRecord(sequence: sequence, events: events)) + if let checkpoint { self.checkpoint = checkpoint } + return sequence + } + + func loadReplay(maximumBytes: Int64) async throws -> PersonalHistoryReplay { + PersonalHistoryReplay(records: records, checkpoint: checkpoint, trainedModel: trainedModel) + } + + func saveTrainedModel(_ model: PersonalNextWordStoredModel) async throws { + trainedModel = model + } + + func deleteAll() async throws { + records = [] + checkpoint = nil + trainedModel = nil + } + + func summary() async throws -> PersonalHistorySummary { + PersonalHistorySummary(location: location, approximateBytes: Int64(events.count)) + } + } + + private final class Clock: @unchecked Sendable { + private let lock = NSLock() + private var value: Int64 + init(_ value: Int64) { self.value = value } + var now: Int64 { + get { lock.withLock { value } } + set { lock.withLock { value = newValue } } + } + } + + private final class GateBox: @unchecked Sendable { + private let lock = NSLock() + private var gate: WritingDayFileRecorder.Gate + init(_ gate: WritingDayFileRecorder.Gate) { self.gate = gate } + var value: WritingDayFileRecorder.Gate { + get { lock.withLock { gate } } + set { lock.withLock { gate = newValue } } + } + } +} diff --git a/Tests/TranscriptedWritingTests/Runtime/WritingEntryComposerTests.swift b/Tests/TranscriptedWritingTests/Runtime/WritingEntryComposerTests.swift index a2db1fc53..3c651015e 100644 --- a/Tests/TranscriptedWritingTests/Runtime/WritingEntryComposerTests.swift +++ b/Tests/TranscriptedWritingTests/Runtime/WritingEntryComposerTests.swift @@ -447,6 +447,100 @@ struct WritingEntryComposerTests { #expect(saved == ["sudo apt update"]) } + // MARK: - Personal History gets only closed, secret-free entries + + @Test("Personal History gets nothing from an open entry, then its typed and accepted events in keyboard order once it closes") + func clearedHistoryWaitsForClose() throws { + var composer = Self.composer() + let first = Self.typed("Pushing teh", at: Self.start) + let backspace = try Self.deletion(2, session: "chain_1", at: Self.start + 1_000) + let accepted = Self.typed("he launch", source: .acceptedSuggestion, session: "chain_1", at: Self.start + 2_000) + let last = Self.typed(" to Thursday", session: "chain_1", at: Self.start + 2_500) + _ = composer.ingest([first, backspace, accepted, last], receivedAt: Self.date(Self.start + 3_000)) + #expect(composer.takeClearedHistory().isEmpty) + + _ = composer.closeAll() + // Deletions carry no text and Personal History doesn't take them. + #expect(composer.takeClearedHistory().map(\.id) == [first.id, accepted.id, last.id]) + #expect(composer.takeClearedHistory().isEmpty) + } + + @Test("An entry too short to save still goes to Personal History") + func clearedHistoryIncludesUnsavedShortEntry() { + var composer = Self.composer() + let event = Self.typed("k", session: "chain-a", at: Self.start) + _ = composer.ingest([event], receivedAt: Self.date(Self.start + 200)) + #expect(composer.closeAll().isEmpty) + #expect(composer.takeClearedHistory().map(\.id) == [event.id]) + } + + @Test("Terminal: a sudo password's entry gives Personal History nothing, and the sudo line still goes") + func clearedHistoryDropsSudoPassword() { + var composer = Self.composer() + let command = Self.typed("sudo apt update", session: "chain-a", app: Self.terminal, at: Self.start) + _ = composer.ingest([command], receivedAt: Self.date(Self.start + 500)) + _ = composer.ingest( + [Self.typed("Tr0ub4dor&3", session: "chain-b", app: Self.terminal, at: Self.start + 2_000)], + receivedAt: Self.date(Self.start + 2_200) + ) + _ = composer.closeAll() + #expect(composer.takeClearedHistory().map(\.id) == [command.id]) + } + + @Test("Terminal: a sudo line folded into one entry with its password gives Personal History nothing") + func clearedHistoryDropsFoldedSudoEntry() { + var composer = Self.composer() + _ = composer.ingest([ + Self.typed("sudo -v", session: "chain-a", app: Self.terminal, at: Self.start), + Self.typed("hunter2", session: "chain-b", app: Self.terminal, at: Self.start + 2_000), + ], receivedAt: Self.date(Self.start + 2_200)) + _ = composer.closeAll() + #expect(composer.takeClearedHistory().isEmpty) + } + + @Test("A secret removed with Backspace before the entry closed gives Personal History nothing, though the saved text is clean") + func clearedHistoryDropsBackspacedSecret() throws { + var composer = Self.composer() + _ = composer.ingest([ + Self.typed("my card is ", at: Self.start), + Self.typed("4111 ", at: Self.start + 500), + Self.typed("1111 ", at: Self.start + 1_000), + Self.typed("1111 ", at: Self.start + 1_500), + Self.typed("1111", at: Self.start + 2_000), + try Self.deletion(19, session: "chain_1", at: Self.start + 3_000), + Self.typed("on file", session: "chain_1", at: Self.start + 4_000), + ], receivedAt: Self.date(Self.start + 4_500)) + #expect(composer.closeAll().map(\.text) == ["my card is on file"]) + #expect(composer.takeClearedHistory().isEmpty) + } + + @Test("A card typed over four boxes gives Personal History none of the boxes") + func clearedHistoryDropsSplitCard() { + var composer = Self.composer() + let boxes = ["4111", "1111", "1111", "1111", "12/28", "123"] + var cleared: [PersonalHistoryEvent] = [] + for (index, box) in boxes.enumerated() { + let at = Self.start + Int64(index) * 1_500 + _ = composer.ingest( + [Self.typed(box, session: "box-\(index)", app: "com.apple.Safari", at: at)], + receivedAt: Self.date(at + 200) + ) + cleared += composer.takeClearedHistory() + } + _ = composer.closeAll() + cleared += composer.takeClearedHistory() + #expect(!cleared.contains { $0.text.contains { $0.isNumber } }) + } + + @Test("A discarded open entry gives Personal History nothing") + func clearedHistoryDropsDiscardedEntry() { + var composer = Self.composer() + _ = composer.ingest([Self.typed("never saved", at: Self.start)], receivedAt: Self.date(Self.start)) + composer.discardOpenEntry() + _ = composer.closeAll() + #expect(composer.takeClearedHistory().isEmpty) + } + // MARK: - Helpers private static func composer() -> WritingEntryComposer { From bdb731e75a216b3b4ee1b236c181af4c9ebb7a74 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Mon, 28 Sep 2026 22:34:41 -0500 Subject: [PATCH 3/3] Ledger: say where a refused Personal History batch shows up, and what a Backspace split costs Review follow-ups. Storage health has no Writing tab line, so a refused batch is only a local diagnostic; that's now a listed follow-up. A Backspace can leave a word alone on a line in the as-sent check and withhold an ordinary entry from learning, never the reverse. --- .../Runtime/SaveMyWriting/WritingEntryComposer.swift | 7 ++++--- docs/writing-port-ledger.md | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/TranscriptedWriting/Runtime/SaveMyWriting/WritingEntryComposer.swift b/Sources/TranscriptedWriting/Runtime/SaveMyWriting/WritingEntryComposer.swift index 5d22d1026..78cd85755 100644 --- a/Sources/TranscriptedWriting/Runtime/SaveMyWriting/WritingEntryComposer.swift +++ b/Sources/TranscriptedWriting/Runtime/SaveMyWriting/WritingEntryComposer.swift @@ -120,9 +120,10 @@ struct WritingEntryComposer { var hasOpenEntry: Bool { open != nil } - /// The Personal History events of every entry closed since the last - /// call, in keyboard order, and forgets them. Entries too short to save - /// still count; an entry that held a secret adds nothing. + /// The typed and accepted events of every entry closed since the last + /// call, in keyboard order, and forgets them. Never deletions. Entries + /// too short to save still count; an entry that held a secret adds + /// nothing. mutating func takeClearedHistory() -> [PersonalHistoryEvent] { defer { clearedHistory.removeAll() } return clearedHistory diff --git a/docs/writing-port-ledger.md b/docs/writing-port-ledger.md index b9ee97591..bb0cc0004 100644 --- a/docs/writing-port-ledger.md +++ b/docs/writing-port-ledger.md @@ -158,7 +158,7 @@ python3 ~/tilde-port/parity-diff.py --ledger docs/writing-port-ledger.md --repo - **History retries (phase 3 review):** Tilde retried a history batch the app refused every 5 s forever, so one event an older app can't read (a version-2 deletion) blocked all text behind it. Now the app answers a version-1 history batch that holds events of an unknown version with a terminal `unsupported` line naming those events' IDs (`GhostBrainRequest.unsupportedPersonalHistoryEventIDs`), and the keyboard drops only them and resends the rest. Any event the app refuses 12 times (`invalid_request`, `error`, an `unsupported` line naming nothing it sent) is dropped too. Not reaching the app (`unavailable`, `timeout`) still retries as in Tilde, and version-1 traffic is unchanged. - **Backspace counts (phase 3 review):** a deletion event's `deletedCharacters` counts UTF-16 units, not `Character`s. The keyboard reports the `utf16Length` of the character it inserted, and `WritingEntryComposer` removes that many units from the joined text. Counting `Character`s removed too much when a combining mark or joiner typed on its own merged into the character before it. Plain Backspace also passes the real Secure Event Input state to the capture permit (it was always `false`, safe only because of an earlier return). - **Secrets in day files:** Save my writing runs every entry through `WritingSecretScrubber` (Core, not in Tilde) before it's saved, and rescrubs day files already on disk once per rules version. It catches what secure input misses: terminal password prompts, password-shaped lines, one-time codes, split card numbers, labelled values and token formats. For that, `SecretRules.ScrubConfig` gained `scrubGenericTokens` and `scrubCardNumbers` (both default on, so Tilde's configs behave the same); the scrubber turns them off and runs stricter versions, because the loose ones took paths, SCREAMING_SNAKE names and UUIDs. Measured with `bash scripts/dev/measure-writing-scrubber.sh`. -- **Secrets in Personal History:** Tilde sent every keyboard batch straight to the encrypted log and the next-word predictor, guarded only by secure input and the password-manager list. So a password typed at a `sudo` prompt in Terminal, an OTP or a card number was stored, and a password with letters in it (`Tr0ub4dor&3` learns "ub" after "Tr") could be learned and served as a ghost suggestion. Now Personal History is reached only through Save my writing's `WritingEntryComposer`: an entry's events go to the controller once the entry closes (a new segment, 2 minutes idle, the app switching, quit), and only if `WritingSecretScrubber` redacts nothing from it, judged with the same per-app context lines the day files use, both as saved and as the keyboard sent it (a secret typed and then Backspaced out is still in the sent events). An entry with a secret gives Personal History nothing at all, including its ordinary words. Scrubbing the event stream itself doesn't work: a secret spans events and segments (a sudo password is the segment after the command, a card is four boxes), and what counts depends on text that hasn't arrived yet. Refusing at the predictor alone doesn't either: it learns letter runs (`Tr`, `ub`, `dor`), which the scrubber can't judge, and the log would still keep the raw text. What changes from Tilde: learning waits for the entry to close; the socket acknowledges a batch once it's composed, so a batch the controller then refuses (storage down) isn't retried, and the storage health line still says History isn't saving; an entry still open at quit is handed off but lands only if the app lives long enough; a retried batch reaches the log once (the recorder's event-ID check), not twice. `PersonalHistoryController` and the predictor are unchanged. Wiring: `WritingHistoryIngest` → `WritingDayFileRecorder` → `PersonalHistoryRelay` → the controller. +- **Secrets in Personal History:** Tilde sent every keyboard batch straight to the encrypted log and the next-word predictor, guarded only by secure input and the password-manager list. So a password typed at a `sudo` prompt in Terminal, an OTP or a card number was stored, and a password with letters in it (`Tr0ub4dor&3` learns "ub" after "Tr") could be learned and served as a ghost suggestion. Now Personal History is reached only through Save my writing's `WritingEntryComposer`: an entry's events go to the controller once the entry closes (a new segment, 2 minutes idle, the app switching, quit), and only if `WritingSecretScrubber` redacts nothing from it, judged with the same per-app context lines the day files use, both as saved and as the keyboard sent it (a secret typed and then Backspaced out is still in the sent events). An entry with a secret gives Personal History nothing at all, including its ordinary words. Scrubbing the event stream itself doesn't work: a secret spans events and segments (a sudo password is the segment after the command, a card is four boxes), and what counts depends on text that hasn't arrived yet. Refusing at the predictor alone doesn't either: it learns letter runs (`Tr`, `ub`, `dor`), which the scrubber can't judge, and the log would still keep the raw text. What changes from Tilde: learning waits for the entry to close; the socket acknowledges a batch once it's composed, so a batch the controller then refuses (storage down, a missing Keychain key) isn't retried and only the local `personal-history-write-failed` diagnostic records it (no Writing tab line shows storage health yet); a Backspace starts a new line in the as-sent check, so a word left alone on one (`Python3` in a browser) can withhold an ordinary entry from learning, never the other way round; an entry still open at quit is handed off but lands only if the app lives long enough; a retried batch reaches the log once (the recorder's event-ID check), not twice. `PersonalHistoryController` and the predictor are unchanged. Wiring: `WritingHistoryIngest` → `WritingDayFileRecorder` → `PersonalHistoryRelay` → the controller. - **Helper API key (security review):** Tilde ran `llama-server` open on loopback with CORS `*` and its web UI on. Loopback TCP isn't per-user, and `/completion` reports `timings.cache_n`, so any local process (another account too) or a web page could prefix-probe the last prompt, which holds typed text and Screen Memory text. Transcripted's host now issues a fresh 32-byte `SecRandomCopyBytes` key per launch (`LlamaServerAccessKey.swift`, memory only, never logged or saved), hands it to the helper as `LLAMA_API_KEY` in the environment so `ps` can't show it, adds `--no-webui`, and every app request (completions, scaffold prewarm, both health probes) sends `Authorization: Bearer `. The pinned build (llama.cpp `2115b73`) honors both; `/health` stays public there, which is fine. - **Socket path:** it's about 9 bytes longer than Tilde's. Usernames over about 28 characters would exceed `sun_path` (104 bytes), and Writing then stays off with a log line. Rare; revisit if it's ever reported. @@ -166,6 +166,7 @@ python3 ~/tilde-port/parity-diff.py --ledger docs/writing-port-ledger.md --repo - Check with real files: Return, Tab, arrows and shortcuts break segments, so each Slack message or Notes paragraph becomes its own writing entry. Decide at the phase 3 checkpoint whether consecutive segments in one app should merge. - Narrowing the app scope doesn't retroactively remove already-stored encrypted personal history; replay filters by the exclusion list only. +- Personal History storage failures are silent to the user: `PersonalHistoryController.storageHealthSnapshot` has no UI since the menu row went (decision 12). Since the secrets fix the keyboard no longer retries a refused batch, so a broken store drops learning with only a local diagnostic. Decide whether the Writing tab shows it. - Personal History written by development builds before the secrets fix may still hold secrets in the encrypted log and the saved trained model (no release shipped Writing before it: 1.1.66 has none). Delete all writing clears both; nothing rescrubs them in place, the way day files are rescrubbed. - Phase 2 cleanup: collapse `.preview9B` into a Qwen completion profile once nothing reads its preview identities. - Done: `llama-server` provenance is verified. A from-source rebuild of llama.cpp `2115b73` matches the pinned code hash exactly; see [llama-server-provenance.md](llama-server-provenance.md). The pin fails closed if a toolchain ever changes how `codesign --remove-signature` lays out bytes.