Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/01-product-spec/implementation-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 低 | |
Expand Down
2 changes: 1 addition & 1 deletion docs/en/01-product-spec/implementation-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down
120 changes: 120 additions & 0 deletions packages/swift-core/DemoSafe/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String.Index>] = []

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
}

Expand Down
Loading