From 2bf543e6485d004a4f46d02aa702d94d566f9fb7 Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 18:56:33 +0200 Subject: [PATCH] Leave sign-in and verification fields alone Cotabby offered completions in Google's "Email or phone" box: a guess at the user's identity, one Tab away from being typed into a login form. Browsers mark only passwords as secure, so password fields were already skipped but email, username, phone and code fields were not. CredentialFieldDetector (pure) blocks single-line fields whose label (title, description, placeholder) or DOM id names a sign-in or verification input, and any single-line field whose whole value is an email address. Multi-line fields are never blocked, so an email body still gets completions. The resolver makes the four attribute reads only for text fields and combo boxes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LptEE4YbaWL55YwQRnTt74 --- Cotabby.xcodeproj/project.pbxproj | 10 +++ .../Resolution/FocusSnapshotResolver.swift | 15 ++++ .../CredentialFieldDetector.swift | 83 +++++++++++++++++++ .../CredentialFieldDetectorTests.swift | 47 +++++++++++ 4 files changed, 155 insertions(+) create mode 100644 Cotabby/Support/Accessibility/CredentialFieldDetector.swift create mode 100644 CotabbyTests/Support/Accessibility/CredentialFieldDetectorTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index ddd90ac8..a3d34f4a 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -161,6 +161,7 @@ 257302D78C5AE9951C63FCEE /* SuggestionAnchorCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE7DB9EE77511823EDA7B52E /* SuggestionAnchorCacheTests.swift */; }; 25F7E6EC713F8F71DEEEAAA3 /* SystemUIFocusShadowPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC384A90F3D8B71584B3449 /* SystemUIFocusShadowPolicy.swift */; }; 26067524E60D738791E983CD /* SOURCES.md in Resources */ = {isa = PBXBuildFile; fileRef = 054987E76CA9D1FA4F81EA8F /* SOURCES.md */; }; + 263CF31EDC4FAD8041831291 /* CredentialFieldDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D9E5C3F2354CC40C0B2051B /* CredentialFieldDetector.swift */; }; 26EA96EB13B94A68276FA15E /* MenuBarRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E267F3BB7FC8DEAEB5E841B /* MenuBarRecoveryPolicy.swift */; }; 2740742B866BC12043B81268 /* TypefaceEvidence.swift in Sources */ = {isa = PBXBuildFile; fileRef = B616CB46A8624BC4E4FBB01A /* TypefaceEvidence.swift */; }; 27A09D81E47FA601F279EF11 /* FocusCapabilityResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B8D2232F271197468CBC11 /* FocusCapabilityResolver.swift */; }; @@ -595,6 +596,7 @@ 998168DC04A6A13D7D1F3165 /* ModelDownloadManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03CA4BBA3C54F840546033E0 /* ModelDownloadManagerTests.swift */; }; 9A2D50EF4911E45EEB4556D6 /* Aria2OutputParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83457CCF1A50CE83428C363D /* Aria2OutputParser.swift */; }; 9A55EDAF0F5D5127A39351C5 /* ContextBufferNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024DDE8C1CE9BF00DE055990 /* ContextBufferNavigationTests.swift */; }; + 9AAD623DEBAD8AF488C37818 /* CredentialFieldDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D9E5C3F2354CC40C0B2051B /* CredentialFieldDetector.swift */; }; 9AE3398FB0E4696C89550C04 /* EmojiMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA8311FAC345FE431FA89855 /* EmojiMatcher.swift */; }; 9B6C176547D2B6D118572E41 /* BaseCompletionPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1AE4120FA68524700C9324 /* BaseCompletionPromptRenderer.swift */; }; 9B7FE4C9ED6959A6D5181EF5 /* EngineAndModelPaneView+Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A184538FD926947EBB88D9E /* EngineAndModelPaneView+Endpoint.swift */; }; @@ -971,6 +973,7 @@ FCD81796FE4DC55778D57686 /* ConfidenceSuppressionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122298AE151ECEEC175878BF /* ConfidenceSuppressionPolicy.swift */; }; FCEE05402A708C33F9719D7F /* OpenAICompatibleSuggestionEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE9156494BFC0A1E12E0B6C /* OpenAICompatibleSuggestionEngineTests.swift */; }; FDA59446E91261744C6DDFDA /* TypingCadenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA3558520A71033BBF30879 /* TypingCadenceTests.swift */; }; + FDE4A159994BDD6605AFD9E9 /* CredentialFieldDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 365AE69E4E1A3DD3486D203A /* CredentialFieldDetectorTests.swift */; }; FDF71F83A24FBE17F5B63C68 /* ApplicationBundleMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1E28CF46BF59ABDC3056BF /* ApplicationBundleMetadata.swift */; }; FE0922970524121DEC4EF2D9 /* OpenAICompatibleEndpointModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABCE733783332BE52E43D67 /* OpenAICompatibleEndpointModels.swift */; }; FE4F4A0778E7D2ABC9EEC155 /* EngineAndModelPaneView+Power.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF5872EC7795CC1EFF6D0D04 /* EngineAndModelPaneView+Power.swift */; }; @@ -1124,6 +1127,7 @@ 353191D1D8A1C655E1B5F562 /* CapturedInputEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturedInputEventTests.swift; sourceTree = ""; }; 35AA2C8F42B510F013D86C3C /* InsertedTextAdvanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertedTextAdvanceTests.swift; sourceTree = ""; }; 35C0B587D81D87ACB952C95E /* SuggestionSettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsStoreTests.swift; sourceTree = ""; }; + 365AE69E4E1A3DD3486D203A /* CredentialFieldDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialFieldDetectorTests.swift; sourceTree = ""; }; 36652DB88C5948AA4A31524A /* ModelFileValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelFileValidator.swift; sourceTree = ""; }; 3680E1B8FA712A888F509640 /* ClipboardContentDistillerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardContentDistillerTests.swift; sourceTree = ""; }; 377A0BBB59988043005A138A /* FoundationModelSuggestionEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelSuggestionEngineTests.swift; sourceTree = ""; }; @@ -1339,6 +1343,7 @@ 8BE5F414704A8264C2946A50 /* TypoGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypoGateTests.swift; sourceTree = ""; }; 8C151BF4D39485E5CFACFECB /* ApplicationBundleMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBundleMetadataTests.swift; sourceTree = ""; }; 8D881FED12A85FFC20F2C9D7 /* DisplayCoordinateConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayCoordinateConverterTests.swift; sourceTree = ""; }; + 8D9E5C3F2354CC40C0B2051B /* CredentialFieldDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialFieldDetector.swift; sourceTree = ""; }; 8DAD5637347E83DDCF515568 /* SuggestionCoordinator+HostMarkedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+HostMarkedText.swift"; sourceTree = ""; }; 8E542E57459488F3D39A9053 /* PhrasePredictionScoringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhrasePredictionScoringTests.swift; sourceTree = ""; }; 8E89746E8CE7E9487337EE6F /* InsertionSafetyGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionSafetyGate.swift; sourceTree = ""; }; @@ -2419,6 +2424,7 @@ 5B5D1A7D938F633C6EE094F7 /* AXHelper.swift */, 82420F9505E69AE2ADE9F583 /* BlockBreakAlignment.swift */, 3FE7D19D22434E3E24804999 /* CalendarAccessibilityCapturePolicy.swift */, + 8D9E5C3F2354CC40C0B2051B /* CredentialFieldDetector.swift */, 47F1A6BCCA20EBE7314B79D3 /* MailHeaderFieldDetector.swift */, E286B9A912808088FDA6B4C4 /* PermissionOverlayTracker.swift */, 1EE99C84483D3A58D561C61D /* SecureFieldDetector.swift */, @@ -2621,6 +2627,7 @@ 6E2AA5BD865E0BCFB53DC699 /* AXHelperTests.swift */, D681DDF8E81F868C1BC92CB4 /* BlockBreakAlignmentTests.swift */, 863B5A1DC3C4B9668F620245 /* CalendarAccessibilityCapturePolicyTests.swift */, + 365AE69E4E1A3DD3486D203A /* CredentialFieldDetectorTests.swift */, B68F9EDFE2903996F0E5524E /* MailHeaderFieldDetectorTests.swift */, AD003D4EBE530DC0E2B87C24 /* PermissionOverlayTrackerTests.swift */, B8EBC9F1890DD2FFC4884A5C /* SecureFieldDetectorTests.swift */, @@ -3892,6 +3899,7 @@ AF55F1ABEDEA10C76C307CEC /* CotabbyAppEnvironment.swift in Sources */, 4E7F611941736F526C3B9C1B /* CotabbyBrand.swift in Sources */, A5D76116479357C29E2D8405 /* CotabbyDebugOptions.swift in Sources */, + 9AAD623DEBAD8AF488C37818 /* CredentialFieldDetector.swift in Sources */, B803D0491F5CF19735F23B65 /* CurrencyEvaluator.swift in Sources */, 81870F2D46C12CA1E6B19523 /* CurrentWordExtractor.swift in Sources */, 378EE9C111040353A6335454 /* CurrentWordSpellChecker.swift in Sources */, @@ -4219,6 +4227,7 @@ FCC571EC239846F06007BFCA /* CotabbyAppEnvironment.swift in Sources */, 085BB87581DFFA260A630E24 /* CotabbyBrand.swift in Sources */, 7A31E6395C535FF017A1EFE1 /* CotabbyDebugOptions.swift in Sources */, + 263CF31EDC4FAD8041831291 /* CredentialFieldDetector.swift in Sources */, 1F39EE1D5FA0F5D32AFFB028 /* CurrencyEvaluator.swift in Sources */, EA353CCECBFB4D297C865447 /* CurrentWordExtractor.swift in Sources */, C56ABA04AE27A9943368035C /* CurrentWordSpellChecker.swift in Sources */, @@ -4533,6 +4542,7 @@ 57BCDFE786675C9793F3E08E /* ControlTokenMarkersTests.swift in Sources */, F8D1C3FD1A1ACAE87D885D29 /* CotabbyDebugOptionsTests.swift in Sources */, 65D20F8E6309CED34A638D35 /* CotabbyTestFixtures.swift in Sources */, + FDE4A159994BDD6605AFD9E9 /* CredentialFieldDetectorTests.swift in Sources */, 15BE5127E4BE29F6CBEEAA0E /* CurrencyEvaluatorTests.swift in Sources */, 99334CDC1399D03019202E85 /* CurrentWordExtractorTests.swift in Sources */, 81073963BC57B5CA9151B0EC /* CustomRulesTests.swift in Sources */, diff --git a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift index 63f5c035..bd5c07b4 100644 --- a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift +++ b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift @@ -378,6 +378,21 @@ struct FocusSnapshotResolver { return MailHeaderFieldDetector.blockedReason } + // Email, username, phone and code boxes: four attribute reads, only for single-line fields. + if CredentialFieldDetector.mightBeCredentialField(role: candidate.role), + CredentialFieldDetector.isCredentialField( + role: candidate.role, + labels: [ + AXHelper.stringValue(for: kAXTitleAttribute as CFString, on: candidate.element), + AXHelper.stringValue(for: kAXDescriptionAttribute as CFString, on: candidate.element), + AXHelper.stringValue(for: kAXPlaceholderValueAttribute as CFString, on: candidate.element) + ], + domIdentifier: AXHelper.stringValue(for: "AXDOMIdentifier" as CFString, on: candidate.element), + text: candidate.textValue + ) { + return CredentialFieldDetector.blockedReason + } + guard selection.length > 0 else { return nil } if BrowserAppDetector.isChromiumBrowser(bundleIdentifier: bundleIdentifier) { CotabbyLogger.focus.debug( diff --git a/Cotabby/Support/Accessibility/CredentialFieldDetector.swift b/Cotabby/Support/Accessibility/CredentialFieldDetector.swift new file mode 100644 index 00000000..e3a186f2 --- /dev/null +++ b/Cotabby/Support/Accessibility/CredentialFieldDetector.swift @@ -0,0 +1,83 @@ +import ApplicationServices +import Foundation + +/// File overview: +/// Recognizes sign-in and verification fields (email, username, phone, one-time codes, card +/// numbers) where Cotabby stands down, alongside password fields, which arrive as secure fields +/// and are already blocked by the resolver. +/// +/// Why: a completion in "Email or phone" guesses at the user's identity. Accepting one types a +/// wrong address into a login form, and showing one paints a guessed address beside the real one. +/// Nothing in such a field is prose a writer wants continued. Browsers mark only passwords as +/// secure, so these fields are recognized from what the page says about them: its label (title, +/// description, placeholder) and its DOM id, plus the typed text itself looking like an address. +/// +/// Scope: single-line fields only (text fields and combo boxes). A multi-line field labelled +/// "email" is an email *body*, which is exactly where completions belong. Pure: the resolver reads +/// the attributes and asks here. +enum CredentialFieldDetector { + static let blockedReason = "Sign-in and verification fields are left alone." + + /// Words in a field's label that name a credential or verification input, matched as whole + /// words in the lowercased label ("pin" matches "Enter PIN", not "shipping"). + static let labelKeywords: [String] = [ + "email", "e-mail", "username", "user name", "user id", "userid", "login", "log in", "sign in", + "phone", "mobile number", "password", "passcode", "pin", "one-time", "one time code", + "verification code", "security code", "otp", "2fa", "two-factor", "authentication code", + "card number", "cvc", "cvv", "expiry", "expiration" + ] + + /// DOM ids used by common sign-in forms (Google's `identifierId`, and the generic names). + static let identifierKeywords: [String] = [ + "identifierid", "username", "userid", "email", "login", "passwd", "password", "otp", "totp", "phone" + ] + + /// Cheap pre-check so labels are only fetched for single-line fields. + static func mightBeCredentialField(role: String) -> Bool { + role == kAXTextFieldRole as String || role == kAXComboBoxRole as String + } + + static func isCredentialField( + role: String, + labels: [String?], + domIdentifier: String?, + text: String? + ) -> Bool { + guard mightBeCredentialField(role: role) else { return false } + + for label in labels.compactMap({ $0?.lowercased() }) where !label.isEmpty { + if labelKeywords.contains(where: { containsWord($0, in: label) }) { + return true + } + } + + if let id = domIdentifier?.lowercased(), !id.isEmpty, + identifierKeywords.contains(where: { id.contains($0) }) { + return true + } + + return looksLikeEmailAddress(text) + } + + /// The whole value is one address-shaped token ("name@domain.tld"), as typed into a login box. + static func looksLikeEmailAddress(_ text: String?) -> Bool { + guard let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return false } + return text.range(of: #"^[^\s@]+@[^\s@]+\.[^\s@]+$"#, options: .regularExpression) != nil + } + + /// `keyword` appears in `label` with no letter or digit directly before or after it, so "pin" + /// matches "Enter PIN" but not "shipping". + private static func containsWord(_ keyword: String, in label: String) -> Bool { + var searchRange = label.startIndex.. Bool = { $0.map { !$0.isLetter && !$0.isNumber } ?? true } + if isBoundary(before) && isBoundary(after) { + return true + } + searchRange = found.upperBound..