diff --git a/CLAUDE.md b/CLAUDE.md index 13ce447..143fb82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,9 @@ These are absolute rules — never violate them: - **Sequential paste pre-fetch**: `SequentialPasteEngine` pre-fetches ALL key values from Keychain before starting the paste sequence. This ensures Keychain auth dialogs (if any) appear upfront, not between ⌘V→Tab→⌘V steps. Phase 2 writes directly to `NSPasteboard`, bypassing `ClipboardEngine.copyToClipboard`. - **CGEvent modifier isolation**: When simulating ⌘V followed by Tab, the Tab `CGEvent` must explicitly set `flags = []` to clear residual Command modifier. Without this, the system interprets Tab as ⌘Tab (app switcher). Use `CGEventSource(stateID: .combinedSessionState)` for isolated event sources. - **Paste key shortcut**: Changed from `⌃⌥[1-9]` to `⌃⌥⌘[1-9]` to avoid conflicts with system/app shortcuts. All ⌃⌥⌘ shortcuts are consistent: D (demo), V (capture), [1-9] (paste). +- **Clipboard capture whitespace stripping**: `detectKeysInClipboard()` strips all whitespace and newlines from clipboard content before pattern matching (`components(separatedBy: .whitespacesAndNewlines).joined()`). API keys never contain spaces, but clipboard copy may introduce line breaks from word-wrap. +- **Clipboard capture built-in patterns**: `ClipboardEngine.builtInCapturePatterns` contains 22 well-known API key patterns (OpenAI, Anthropic, GitHub, AWS, Google, Stripe, etc.) for detecting NEW keys not yet in the vault. This is separate from `patternCacheEntries()` which only matches stored keys. +- **NSAlert in menu bar app**: Must call `alert.layout()` then set `alert.window.level = .floating` + `orderFrontRegardless()` before `runModal()`. Without this, the alert either doesn't appear or creates a dock icon. Do NOT use `setActivationPolicy(.regular)`. ## Documentation diff --git a/README.md b/README.md index 3c95dcc..fe56a08 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ See the [docs/](docs/) directory for detailed specifications: - [x] Native Messaging Host dual-path IPC (WS primary + NMH fallback) - [x] Smart Key Extraction confirmation dialog (full Chrome ↔ Swift Core IPC: detect → submit → Keychain store → pattern sync) - [x] Linked Key Groups (sequential paste with ⌘V→Tab automation, Settings UI CRUD, pre-fetch Keychain) +- [x] Clipboard Capture (⌃⌥⌘V hotkey, 22 built-in patterns, 3-tier confidence routing, whitespace-tolerant) - [ ] API Key rotation & deployment sync - [ ] Terminal masking (node-pty proxy) - [ ] System-wide masking (Accessibility API) diff --git a/docs/01-product-spec/implementation-status.md b/docs/01-product-spec/implementation-status.md index 156beac..f62104b 100644 --- a/docs/01-product-spec/implementation-status.md +++ b/docs/01-product-spec/implementation-status.md @@ -32,7 +32,7 @@ | 功能 | 對應 Spec 章節 | 優先順序 | 備註 | |------|--------------|---------|---------| -| `⌃⌥⌘V` capture clipboard | Spec §4.4 | 中 | HotkeyManager 端尚未掛載 ClipboardEngine.detectKeys | +| ~~`⌃⌥⌘V` capture clipboard~~ | Spec §4.4 | ~~中~~ | ✅ HotkeyManager → ClipboardEngine.detectKeysInClipboard() → 內建 22 種 pattern 偵測 → confidence 三階路由 → VaultManager 存儲 | | ~~Linked Key Groups (sequential paste)~~ | Spec §6.3 | ~~中~~ | ✅ `LinkedGroup`/`GroupEntry`/`SequentialPasteEngine` 完成、Settings UI 群組管理(CRUD)、`request_paste_group` IPC handler | | Shortcut conflict detection | Spec §4.4 | 低 | | | Import / Export vault | Spec §9.1 | 低 | | diff --git a/docs/en/01-product-spec/implementation-status.md b/docs/en/01-product-spec/implementation-status.md index 812ca2e..9e21d3a 100644 --- a/docs/en/01-product-spec/implementation-status.md +++ b/docs/en/01-product-spec/implementation-status.md @@ -32,7 +32,7 @@ | Feature | Spec Section | Priority | Notes | |---------|-------------|----------|---------| -| `⌃⌥⌘V` capture clipboard | Spec §4.4 | Medium | HotkeyManager not yet wired to ClipboardEngine.detectKeys | +| ~~`⌃⌥⌘V` capture clipboard~~ | Spec §4.4 | ~~Medium~~ | ✅ HotkeyManager → ClipboardEngine.detectKeysInClipboard() → 22 built-in patterns → 3-tier confidence routing → VaultManager store | | ~~Linked Key Groups (sequential paste)~~ | Spec §6.3 | ~~Medium~~ | ✅ `LinkedGroup`/`GroupEntry`/`SequentialPasteEngine` complete, Settings UI group management (CRUD), `request_paste_group` IPC handler | | Shortcut conflict detection | Spec §4.4 | Low | | | Import / Export vault | Spec §9.1 | Low | | diff --git a/packages/swift-core/DemoSafe/App/AppState.swift b/packages/swift-core/DemoSafe/App/AppState.swift index 73eee4e..d5f6381 100644 --- a/packages/swift-core/DemoSafe/App/AppState.swift +++ b/packages/swift-core/DemoSafe/App/AppState.swift @@ -180,6 +180,11 @@ final class AppState: ObservableObject { self?.toggleDemoMode() } + // Capture clipboard + hotkeyManager.onCaptureClipboard = { [weak self] in + self?.handleCaptureClipboard() + } + // Paste key by index (group-aware) hotkeyManager.onPasteKeyByIndex = { [weak self] index in guard let self else { return } @@ -205,6 +210,121 @@ final class AppState: ObservableObject { } } + // MARK: - Clipboard Capture + + private func handleCaptureClipboard() { + let detected = clipboardEngine.detectKeysInClipboard() + + if detected.isEmpty { + logger.info("Capture clipboard: no keys detected") + _ = showAlert(title: "No Keys Found", message: "No API key patterns detected in clipboard content.") + return + } + + logger.info("Capture clipboard: found \(detected.count) key(s)") + + for key in detected { + if key.confidence >= 0.7 { + // High confidence — auto-store + storeDetectedKey(key) + } else if key.confidence >= 0.35 { + // Medium confidence — confirm with user + let prefix = String(key.rawValue.prefix(8)) + let suffix = String(key.rawValue.suffix(4)) + let preview = "\(prefix)...\(suffix) (\(key.rawValue.count) chars)" + + let response = showAlert( + title: "Store Detected Key?", + message: "Service: \(key.suggestedService ?? "Unknown")\nKey: \(preview)\nConfidence: \(Int(key.confidence * 100))%", + buttons: ["Store", "Skip"] + ) + if response == .alertFirstButtonReturn { + storeDetectedKey(key) + } else { + logger.info("Capture clipboard: user skipped key") + } + } else { + // Low confidence — ignore + logger.info("Capture clipboard: ignoring low confidence key (\(key.confidence))") + } + } + } + + private func storeDetectedKey(_ detected: DetectedKey) { + let rawValue = detected.rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + let serviceName = detected.suggestedService ?? "Unknown" + + // Find or create service + var service = vaultManager.getAllServices().first(where: { $0.name == serviceName }) + if service == nil { + let newService = Service( + id: UUID(), name: serviceName, icon: nil, + defaultPattern: ".*", defaultMaskFormat: .default, isBuiltIn: false + ) + try? vaultManager.addService(newService) + service = newService + } + + guard let svc = service, let valueData = rawValue.data(using: .utf8) else { + logger.error("Capture clipboard: failed to encode key value") + return + } + + // Deduplicate + if let existing = vaultManager.isDuplicateKey(serviceId: svc.id, value: valueData) { + logger.info("Capture clipboard: duplicate key '\(existing.label)', skipping") + _ = showAlert(title: "Duplicate Key", message: "This key already exists as '\(existing.label)'.") + return + } + + let label = "\(serviceName.lowercased())-\(Int(Date().timeIntervalSince1970) % 100000)" + let structuralPattern = IPCServer.deriveStructuralPattern(from: rawValue) + + do { + _ = try vaultManager.addKey( + label: label, + serviceId: svc.id, + pattern: structuralPattern, + maskFormat: svc.defaultMaskFormat, + value: valueData + ) + logger.info("Capture clipboard: stored key '\(label)'") + _ = showAlert(title: "Key Stored", message: "Saved as '\(label)' under \(serviceName).") + } catch { + logger.error("Capture clipboard: store failed: \(error)") + } + } + + private func showAlert(title: String, message: String, buttons: [String] = ["OK"]) -> NSApplication.ModalResponse { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .informational + for button in buttons { + alert.addButton(withTitle: button) + } + + // Use lock icon instead of default folder icon + alert.icon = NSImage(systemSymbolName: "lock.shield", accessibilityDescription: "DemoSafe") + + // Pre-configure window to float above all windows + alert.layout() + alert.window.level = .floating + + // Position near top-right (like system notifications) + if let screen = NSScreen.main { + let screenFrame = screen.visibleFrame + let alertSize = alert.window.frame.size + let x = screenFrame.maxX - alertSize.width - 20 + let y = screenFrame.maxY - alertSize.height - 20 + alert.window.setFrameOrigin(NSPoint(x: x, y: y)) + } + + alert.window.orderFrontRegardless() + + return alert.runModal() + } + // MARK: - Private private func seedDefaultContextModes() { diff --git a/packages/swift-core/DemoSafe/Services/Clipboard/ClipboardEngine.swift b/packages/swift-core/DemoSafe/Services/Clipboard/ClipboardEngine.swift index 9aedfe7..8ec5512 100644 --- a/packages/swift-core/DemoSafe/Services/Clipboard/ClipboardEngine.swift +++ b/packages/swift-core/DemoSafe/Services/Clipboard/ClipboardEngine.swift @@ -87,32 +87,99 @@ final class ClipboardEngine { } } + /// Well-known API key patterns for clipboard capture detection. + /// These detect NEW keys not yet in the vault, unlike patternCacheEntries() which only matches stored keys. + static let builtInCapturePatterns: [(pattern: String, service: String, confidence: Double)] = [ + // OpenAI + ("sk-proj-[A-Za-z0-9_-]{20,}", "OpenAI", 0.95), + ("sk-ant-api03-[A-Za-z0-9_-]{20,}", "Anthropic", 0.95), + ("sk-ant-[A-Za-z0-9_-]{20,}", "Anthropic", 0.90), + // GitHub + ("ghp_[A-Za-z0-9]{36,}", "GitHub", 0.95), + ("gho_[A-Za-z0-9]{36,}", "GitHub", 0.95), + ("ghu_[A-Za-z0-9]{36,}", "GitHub", 0.95), + ("ghs_[A-Za-z0-9]{36,}", "GitHub", 0.95), + ("ghr_[A-Za-z0-9]{36,}", "GitHub", 0.95), + // GitLab + ("glpat-[A-Za-z0-9_-]{20,}", "GitLab", 0.90), + // Google Cloud + ("AIzaSy[A-Za-z0-9_-]{33}", "Google Cloud", 0.90), + // AWS + ("AKIA[A-Z0-9]{16}", "AWS", 0.90), + ("ASIA[A-Z0-9]{16}", "AWS", 0.85), + // Slack + ("xoxb-[A-Za-z0-9-]{20,}", "Slack", 0.90), + ("xoxp-[A-Za-z0-9-]{20,}", "Slack", 0.90), + // HuggingFace + ("hf_[A-Za-z0-9]{20,}", "HuggingFace", 0.90), + // Stripe + ("sk_live_[A-Za-z0-9]{20,}", "Stripe", 0.90), + ("sk_test_[A-Za-z0-9]{20,}", "Stripe", 0.85), + ("pk_live_[A-Za-z0-9]{20,}", "Stripe", 0.85), + ("pk_test_[A-Za-z0-9]{20,}", "Stripe", 0.80), + // SendGrid + ("SG\\.[A-Za-z0-9_-]{20,}", "SendGrid", 0.85), + // Generic (lower confidence) + ("sk-[A-Za-z0-9_-]{30,}", "Unknown API", 0.60), + ] + /// Scan current clipboard content for API key patterns. - /// Uses MaskingCoordinator's compiled patterns for detection. + /// Uses both built-in capture patterns (for detecting NEW keys) and + /// MaskingCoordinator's cached patterns (for detecting stored keys). func detectKeysInClipboard() -> [DetectedKey] { - guard let coordinator = maskingCoordinator else { return [] } - guard let content = NSPasteboard.general.string(forType: .string) else { return [] } + guard let rawContent = NSPasteboard.general.string(forType: .string) else { return [] } + // Strip all whitespace and newlines — API keys never contain spaces, + // but clipboard content may have line breaks from word-wrap or copy artifacts + let content = rawContent.components(separatedBy: .whitespacesAndNewlines).joined() - let keys = coordinator.patternCacheEntries() var detected: [DetectedKey] = [] + var matchedRanges: [Range] = [] - for entry in keys { + // Phase 1: Built-in capture patterns (detect new keys) + for entry in Self.builtInCapturePatterns { guard let regex = try? NSRegularExpression(pattern: entry.pattern) else { continue } let nsRange = NSRange(content.startIndex..., in: content) let matches = regex.matches(in: content, range: nsRange) for match in matches { guard let range = Range(match.range, in: content) else { continue } + // Skip overlapping matches + if matchedRanges.contains(where: { $0.overlaps(range) }) { continue } + matchedRanges.append(range) + let rawValue = String(content[range]) detected.append(DetectedKey( rawValue: rawValue, - suggestedService: entry.serviceName, + suggestedService: entry.service, pattern: entry.pattern, - confidence: calculateConfidence(pattern: entry.pattern, match: rawValue) + confidence: entry.confidence )) } } + // Phase 2: Vault pattern cache (detect stored keys already known) + if let coordinator = maskingCoordinator { + for entry in coordinator.patternCacheEntries() { + guard let regex = try? NSRegularExpression(pattern: entry.pattern) else { continue } + let nsRange = NSRange(content.startIndex..., in: content) + let matches = regex.matches(in: content, range: nsRange) + + for match in matches { + guard let range = Range(match.range, in: content) else { continue } + if matchedRanges.contains(where: { $0.overlaps(range) }) { continue } + matchedRanges.append(range) + + let rawValue = String(content[range]) + detected.append(DetectedKey( + rawValue: rawValue, + suggestedService: entry.serviceName, + pattern: entry.pattern, + confidence: calculateConfidence(pattern: entry.pattern, match: rawValue) + )) + } + } + } + return detected }