Skip to content

Fail-open accept tap: never consume bare printables, never consume without verified session#382

Merged
FuJacob merged 1 commit into
mainfrom
audit/input-swallow
May 28, 2026
Merged

Fail-open accept tap: never consume bare printables, never consume without verified session#382
FuJacob merged 1 commit into
mainfrom
audit/input-swallow

Conversation

@FuJacob

@FuJacob FuJacob commented May 28, 2026

Copy link
Copy Markdown
Owner

Root cause

User report: typing regular letters/numbers in Chrome with a Cotabby suggestion visible silently drops the character. Three subagent investigations converged on the same structural cause introduced by #337 (split-tap model):

The active accept tap (.defaultTap, tail) returns nil and swallows any keystroke whose (keyCode, modifiers) matches the user's bound accept binding. The matching is correct in isolation, but:

  • KeyRecorderView accepts and stores any keycode the user presses, including bare alphanumerics. (keyCode: 0, modifiers: []) is a valid stored binding meaning 'a'. From that moment every bare 'a' in any app while a suggestion is visible is eaten.
  • The default full-accept binding is bare backtick. Users type backticks regularly.
  • The matching uses providers read at event time. There is no in-the-moment check that a real, ready, valid session exists when the tap consumes.

Fix (two layers, both fail-open)

  1. Bare-printable safeguard. In handleAcceptTap, if the binding match would fire for a keystroke that has no modifiers and produces a non-control printable, refuse to consume and warn in the log. Acceptance via a bare letter is incoherent (the same press cannot both insert the user's character and trigger acceptance).
  2. Coordinator authorization. New shouldConsumeAcceptKeyProvider closure on InputMonitor. The coordinator wires it to a live check: state == .ready AND interactionState.activeSession != nil AND overlayState.isVisible. The tap only consumes when all three hold at event time. Any stale install, lifecycle gap, or settings race collapses to pass-through.

Plus debug logging on every consume decision so future swallow reports can be diagnosed from the runtime log.

Why this prevents the symptom

  • A corrupt bare-letter binding can no longer eat typing — Layer 1 catches it.
  • A stale active tap surviving past a session invalidation can no longer eat typing — Layer 2 catches it.
  • The legitimate acceptance path is unchanged: when state is .ready, the session is live, and the overlay is visible, the predicate returns true and the bound key is consumed as before.

Test plan

  1. Repro the swallow: bind bare 'a' in Shortcuts, type 'a' in Chrome with a suggestion visible. On main the character disappears. With this PR it reaches Chrome and a warning lands in the runtime log.
  2. Backtick: leave default binding, type code containing backticks in Chrome with a suggestion visible. On main the backtick disappears. With this PR it reaches Chrome.
  3. Stale-session race: prime a state where the overlay is briefly visible but state has flipped to .debouncing. The accept key now falls through instead of being consumed.
  4. Regression: bind ⌃Tab, generate a suggestion, press ⌃Tab. Acceptance still fires and the keystroke does not reach the host.
  5. Build + lint clean.

Risk / rollout notes

  • Fail-open by design. The worst case for the fix is that a legitimate accept key is occasionally passed through — the user sees their key reach the host instead of accepting the suggestion, which is the safe failure mode.
  • shouldConsumeAcceptKeyProvider defaults to { false } on InputMonitor, so any test or future call site that constructs the monitor without wiring the coordinator will pass keys through rather than swallow.
  • This does not address the duplicate-Tab bug Agent 3 flagged in passTabThrough's unconditional replay. That is a separate PR.

Greptile Summary

This PR adds two fail-open guards to the active accept tap: Layer 1 refuses to consume keystrokes whose binding has no modifiers and produces a printable character, and Layer 2 delegates the consume decision to a new shouldConsumeAcceptKeyProvider closure wired in SuggestionCoordinator to a live three-condition predicate (state .ready + active session + visible overlay).

  • InputMonitor.swift: handleAcceptTap gains the two guard layers and structured debug/warning logging for every consume decision.
  • SuggestionSubsystemContracts.swift: shouldConsumeAcceptKeyProvider: @MainActor () -> Bool added to SuggestionInputMonitoring, defaulting to { false } on InputMonitor so stale installs are automatically fail-open.
  • SuggestionCoordinator.swift: The provider closure is wired at init time before any overlay callbacks, using [weak self] so deallocation also collapses to false.

Confidence Score: 3/5

The two-tap architecture means the observer tap always drives acceptance independently of what the accept tap subsequently decides — Layer 1 prevents consumption but cannot un-do the acceptance already triggered by the observer for the same event.

The Layer 2 wiring is correct and the fail-open default is safe. Layer 1's bare-printable guard creates an observable double-action for any user with a misconfigured bare-printable binding: the observer tap classifies the keypress as .acceptance and drives suggestion insertion before the accept tap even runs, so acceptance happens AND the character reaches Chrome. Additionally, in the normal acceptance success path the observer hides the overlay (updating overlayState) before the accept tap fires, so Layer 2 reads overlayState.isVisible = false — whether this causes the accept key to leak to the focused app depends on undocumented CFMachPortInvalidate in-flight semantics that should be validated empirically.

Cotabby/Services/Input/InputMonitor.swift — both the Layer 1 guard in handleAcceptTap and the Layer 2 overlayState timing need a second look before merge.

Important Files Changed

Filename Overview
Cotabby/Services/Input/InputMonitor.swift Adds two new guards in handleAcceptTap: Layer 1 rejects bare-printable bindings before consuming, and Layer 2 delegates the consume decision to a new shouldConsumeAcceptKeyProvider closure. The layered approach is sound, but because the observer tap (which fires first) always drives the full acceptance flow independently of what the accept tap ultimately does, a bare-printable binding still triggers suggestion acceptance AND passes the character to Chrome — a double-action regression.
Cotabby/Models/SuggestionSubsystemContracts.swift Adds shouldConsumeAcceptKeyProvider to the SuggestionInputMonitoring protocol. The addition is minimal and correctly typed as @mainactor () -> Bool, consistent with the existing provider pattern in the protocol.
Cotabby/App/Coordinators/SuggestionCoordinator.swift Wires shouldConsumeAcceptKeyProvider to a live three-condition predicate (state == .ready, activeSession != nil, overlayState.isVisible). The [weak self] capture, fail-open default (returns false when self is nil), and init-time placement before any overlay callbacks are all correct.

Sequence Diagram

sequenceDiagram
    participant User
    participant macOS
    participant ObserverTap as Observer Tap (head, listen-only)
    participant Coordinator as SuggestionCoordinator
    participant AcceptTap as Accept Tap (tail, active)
    participant Chrome

    User->>macOS: keyDown (accept key)
    macOS->>ObserverTap: event delivered (1st)
    ObserverTap->>Coordinator: classify → onEvent(.acceptance)
    Coordinator->>Coordinator: acceptSuggestion()
    Note over Coordinator: Guards: session≠nil, state==.ready, overlay visible
    Coordinator->>Coordinator: insert suggestion text
    Coordinator->>Coordinator: hideOverlay → destroyAcceptTap()
    ObserverTap-->>macOS: passUnretained (always)

    macOS->>AcceptTap: event delivered (2nd)
    Note over AcceptTap: Layer 1: isBarePrintable?
    alt "isBarePrintable == true"
        AcceptTap-->>macOS: passUnretained (key NOT consumed)
        macOS->>Chrome: key delivered
    else "isBarePrintable == false"
        Note over AcceptTap: Layer 2: shouldConsumeAcceptKeyProvider()
        alt session ready + active + overlay visible
            AcceptTap-->>macOS: nil (key consumed)
        else any condition false
            AcceptTap-->>macOS: passUnretained (key NOT consumed)
            macOS->>Chrome: key delivered
        end
    end
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Fail-open accept tap: never consume bare..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

@FuJacob FuJacob merged commit 852afee into main May 28, 2026
@FuJacob FuJacob deleted the audit/input-swallow branch May 28, 2026 16:05
Comment on lines +311 to +318
let isBarePrintable = eventModifiers.isEmpty
&& !event.unicodeString.trimmingCharacters(in: .controlCharacters).isEmpty
if isBarePrintable {
let message = "Accept tap refusing to consume bare printable keyCode=\(keyCode). "
+ "Rebind with a modifier in Settings > Shortcuts."
CotabbyLogger.app.warning("\(message)")
return Unmanaged.passUnretained(event)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Double-action when a bare-printable binding matches an active session

Layer 1 prevents the accept tap from consuming the key, but it cannot prevent the observer tap (which fires first, at the head) from having already classified the same keystroke as .acceptance/.fullAcceptance and driven the full acceptance flow through acceptSuggestion. When a session is active, acceptSuggestion inserts the suggestion text and calls hideOverlay — all before the accept tap even runs. Layer 1 then passes the key through to Chrome.

Concrete failure: user binds bare a, types a with a visible suggestion. The observer fires, acceptSuggestion succeeds and inserts the AI text. Then the accept tap fires: isBarePrintable is true, so a also reaches Chrome. The user sees the suggestion text plus a literal a appended.

The fix would be to also guard classify() (or handleObserverTap) so that it does not emit .acceptance/.fullAcceptance when the binding is a bare printable — or to gate acceptCurrentSuggestion/acceptEntireSuggestion on shouldConsumeAcceptKeyProvider() directly.

Fix in Codex Fix in Claude Code

Comment on lines +325 to +330
guard shouldConsumeAcceptKeyProvider() else {
let message = "Accept tap declining to consume keyCode=\(keyCode): "
+ "coordinator reports no ready session"
CotabbyLogger.app.debug("\(message)")
return Unmanaged.passUnretained(event)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Layer 2 reads overlayState that was updated by the observer callback for the same event

The observer tap fires first (head-inserted), potentially drives a successful acceptance that calls hideOverlay()setAcceptInterceptionActive(false)destroyAcceptTap(), and returns. overlayState is now .hidden. When the accept tap fires next, shouldConsumeAcceptKeyProvider() reads self.overlayState.isVisible = false and returns false, passing the accept key through.

Whether this actually regresses the happy path (Tab/⌃Tab) depends on whether CFMachPortInvalidate prevents the accept tap from being invoked at all for the in-flight event. If the callback is not invoked after invalidation, this is benign; if it is, every successful acceptance would also deliver the accept key to the focused app. The PR's test 4 claim ("keystroke does not reach the host") should be validated against a case where the accept tap is still installed (i.e., where destroyAcceptTap is NOT called during the observer callback).

Fix in Codex Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant