From 88bab350d30db937570da49711d7cc1a6753fd57 Mon Sep 17 00:00:00 2001 From: haotangyuan <1416555145@qq.com> Date: Sat, 13 Jun 2026 12:45:33 +0800 Subject: [PATCH] Remove sensitive content masking --- .../Support/AppDependencyFactory.swift | 8 +- .../DevClip/Support/HistoryViewModel.swift | 10 +- .../DevClip/Support/SettingsViewModel.swift | 2 +- Sources/DevClip/Views/HistoryDetailView.swift | 4 - .../DevClip/Views/HistoryListPaneView.swift | 2 +- Sources/DevClip/Views/SettingsRootView.swift | 27 --- .../EncryptedClipboardArchiveService.swift | 16 +- .../Models/SensitiveClassification.swift | 6 - .../Pasteboard/ClipboardMonitor.swift | 33 +-- .../Pasteboard/ClipboardSnapshotBuilder.swift | 109 +-------- Sources/DevClipCore/Search/SearchQuery.swift | 1 - .../Search/SearchQueryParser.swift | 6 - .../DevClipCore/Search/SearchService.swift | 5 - .../Security/SensitiveContentDetector.swift | 226 ------------------ .../Security/SensitiveEphemeralStore.swift | 61 ----- .../Support/DependencyContainer.swift | 14 +- .../Phase3QuickPanelSearchTests.swift | 8 +- .../Phase4ClassificationSecurityTests.swift | 98 +------- ...Phase8ExportSettingsPerformanceTests.swift | 19 +- docs/IMPLEMENTATION_STATUS.md | 73 ++---- 20 files changed, 68 insertions(+), 660 deletions(-) delete mode 100644 Sources/DevClipCore/Models/SensitiveClassification.swift delete mode 100644 Sources/DevClipCore/Security/SensitiveContentDetector.swift delete mode 100644 Sources/DevClipCore/Security/SensitiveEphemeralStore.swift diff --git a/Sources/DevClip/Support/AppDependencyFactory.swift b/Sources/DevClip/Support/AppDependencyFactory.swift index 553ee23..4c1a8a9 100644 --- a/Sources/DevClip/Support/AppDependencyFactory.swift +++ b/Sources/DevClip/Support/AppDependencyFactory.swift @@ -26,20 +26,16 @@ enum AppDependencyFactory { let pasteboardClient = SystemPasteboardClient() let writeGuard = ClipboardWriteGuard() let contentClassifier = DefaultContentClassifier() - let sensitiveDetector = DefaultSensitiveContentDetector() - let ephemeralSensitiveStore = SensitiveEphemeralStore() let transformEngine = TransformEngine() let snapshotBuilder = ClipboardSnapshotBuilder( contentClassifier: contentClassifier, - sensitiveDetector: sensitiveDetector, blobStore: blobStore ) let monitor = ClipboardMonitor( pasteboardClient: pasteboardClient, repository: repository, writeGuard: writeGuard, - snapshotBuilder: snapshotBuilder, - ephemeralSensitiveStore: ephemeralSensitiveStore + snapshotBuilder: snapshotBuilder ) let pasteEngine = PasteEngine( repository: repository, @@ -62,9 +58,7 @@ enum AppDependencyFactory { repository: repository, writeGuard: writeGuard, clipboardMonitor: monitor, - ephemeralSensitiveStore: ephemeralSensitiveStore, contentClassifier: contentClassifier, - sensitiveDetector: sensitiveDetector, transformEngine: transformEngine, searchService: searchService, blobStore: blobStore, diff --git a/Sources/DevClip/Support/HistoryViewModel.swift b/Sources/DevClip/Support/HistoryViewModel.swift index 6a82d7b..cd1bf16 100644 --- a/Sources/DevClip/Support/HistoryViewModel.swift +++ b/Sources/DevClip/Support/HistoryViewModel.swift @@ -7,7 +7,6 @@ final class HistoryViewModel: ObservableObject { enum Scope: String, CaseIterable, Identifiable { case all case pinned - case sensitive var id: String { rawValue } @@ -17,8 +16,6 @@ final class HistoryViewModel: ObservableObject { "历史" case .pinned: "固定" - case .sensitive: - "敏感" } } @@ -28,8 +25,6 @@ final class HistoryViewModel: ObservableObject { "clock" case .pinned: "pin" - case .sensitive: - "lock.shield" } } } @@ -71,8 +66,7 @@ final class HistoryViewModel: ObservableObject { var counts: [Scope: Int] { [ .all: allEntries.count, - .pinned: allEntries.filter(\.isPinned).count, - .sensitive: allEntries.filter(\.isSensitive).count + .pinned: allEntries.filter(\.isPinned).count ] } @@ -227,8 +221,6 @@ final class HistoryViewModel: ObservableObject { values case .pinned: values.filter(\.isPinned) - case .sensitive: - values.filter(\.isSensitive) } } } diff --git a/Sources/DevClip/Support/SettingsViewModel.swift b/Sources/DevClip/Support/SettingsViewModel.swift index 6e4c59f..40635d2 100644 --- a/Sources/DevClip/Support/SettingsViewModel.swift +++ b/Sources/DevClip/Support/SettingsViewModel.swift @@ -52,7 +52,7 @@ final class SettingsViewModel: ObservableObject { do { let result = try await archiveService.exportEncrypted(passphrase: archivePassphrase) try await archiveFileClient.write(result.archive, to: url(from: exportPath)) - statusMessage = "已导出 \(result.summary.exportedEntryCount) 条,跳过敏感记录 \(result.summary.skippedSensitiveCount) 条" + statusMessage = "已导出 \(result.summary.exportedEntryCount) 条记录" } catch { statusMessage = "导出失败:\(error.localizedDescription)" } diff --git a/Sources/DevClip/Views/HistoryDetailView.swift b/Sources/DevClip/Views/HistoryDetailView.swift index 89d1f6b..5a60f19 100644 --- a/Sources/DevClip/Views/HistoryDetailView.swift +++ b/Sources/DevClip/Views/HistoryDetailView.swift @@ -74,10 +74,6 @@ struct HistoryDetailView: View { @ViewBuilder private func headerBadges(_ entry: ClipboardEntry) -> some View { - if entry.isSensitive { - Label("敏感", systemImage: "lock.fill") - .devClipStatusBadge(color: .orange) - } if entry.isPinned { Label("已固定", systemImage: "pin.fill") .devClipStatusBadge(color: Color.accentColor) diff --git a/Sources/DevClip/Views/HistoryListPaneView.swift b/Sources/DevClip/Views/HistoryListPaneView.swift index e5b6fe2..097b3c7 100644 --- a/Sources/DevClip/Views/HistoryListPaneView.swift +++ b/Sources/DevClip/Views/HistoryListPaneView.swift @@ -98,7 +98,7 @@ private struct HistoryEntryRow: View { } else { Image(systemName: entry.isPinned ? "pin.fill" : ClipboardKindPresentation.iconName(entry.detectedKind)) .font(.system(size: 14)) - .foregroundStyle(entry.isSensitive ? .orange : .secondary) + .foregroundStyle(.secondary) .frame(width: 36, height: 36) .background( RoundedRectangle(cornerRadius: 4, style: .continuous) diff --git a/Sources/DevClip/Views/SettingsRootView.swift b/Sources/DevClip/Views/SettingsRootView.swift index 1afb6c4..42284e5 100644 --- a/Sources/DevClip/Views/SettingsRootView.swift +++ b/Sources/DevClip/Views/SettingsRootView.swift @@ -5,8 +5,6 @@ struct SettingsRootView: View { @StateObject private var viewModel: SettingsViewModel @AppStorage(UserDefaultsPasteAutomationPreferences.automaticPasteEnabledKey) private var isAutomaticPasteEnabled = false - @AppStorage("privacy.maskSensitiveContent") - private var masksSensitiveContent = true init(dependencies: DependencyContainer) { _viewModel = StateObject(wrappedValue: SettingsViewModel(dependencies: dependencies)) @@ -19,11 +17,6 @@ struct SettingsRootView: View { Label("通用", systemImage: "gear") } - privacyTab - .tabItem { - Label("隐私", systemImage: "lock.shield") - } - archiveTab .tabItem { Label("导入导出", systemImage: "lock.doc") @@ -74,26 +67,6 @@ struct SettingsRootView: View { } } - // MARK: - Privacy Tab - - private var privacyTab: some View { - Form { - Section("敏感内容") { - Toggle("遮罩敏感内容", isOn: $masksSensitiveContent) - - Text("启用后,检测到可能包含密码、密钥等敏感信息的剪贴板内容将自动遮罩显示。") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } - - Section("导出安全") { - Text("敏感记录默认不导出,导出文件使用 AES-GCM 加密保护。") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } - } - } - // MARK: - Archive Tab private var archiveTab: some View { diff --git a/Sources/DevClipCore/Export/EncryptedClipboardArchiveService.swift b/Sources/DevClipCore/Export/EncryptedClipboardArchiveService.swift index 8b97df8..6de1ed2 100644 --- a/Sources/DevClipCore/Export/EncryptedClipboardArchiveService.swift +++ b/Sources/DevClipCore/Export/EncryptedClipboardArchiveService.swift @@ -3,11 +3,11 @@ import Foundation public struct ClipboardArchiveExportSummary: Equatable, Sendable { public var exportedEntryCount: Int - public var skippedSensitiveCount: Int + public var skippedEntryCount: Int - public init(exportedEntryCount: Int, skippedSensitiveCount: Int) { + public init(exportedEntryCount: Int, skippedEntryCount: Int) { self.exportedEntryCount = exportedEntryCount - self.skippedSensitiveCount = skippedSensitiveCount + self.skippedEntryCount = skippedEntryCount } } @@ -144,7 +144,7 @@ public actor AESGCMClipboardArchiveService: ClipboardArchiveService { ) let summary = ClipboardArchiveExportSummary( exportedEntryCount: exportableEntries.count, - skippedSensitiveCount: allEntries.count - exportableEntries.count + skippedEntryCount: allEntries.count - exportableEntries.count ) return (archive, summary) @@ -200,14 +200,6 @@ public actor AESGCMClipboardArchiveService: ClipboardArchiveService { } private static func isExportable(_ entry: ClipboardEntry) -> Bool { - if entry.isSensitive { - return false - } - - if entry.metadata.values["sensitiveClassification"] == SensitiveClassification.secret.rawValue { - return false - } - if entry.metadata.values["shouldExport"] == "false" { return false } diff --git a/Sources/DevClipCore/Models/SensitiveClassification.swift b/Sources/DevClipCore/Models/SensitiveClassification.swift deleted file mode 100644 index 63ea288..0000000 --- a/Sources/DevClipCore/Models/SensitiveClassification.swift +++ /dev/null @@ -1,6 +0,0 @@ -/// Sensitivity level that drives retention, indexing, logging, and export policy. -public enum SensitiveClassification: String, Codable, CaseIterable, Sendable { - case none - case potential - case secret -} diff --git a/Sources/DevClipCore/Pasteboard/ClipboardMonitor.swift b/Sources/DevClipCore/Pasteboard/ClipboardMonitor.swift index 7aabc47..e0de533 100644 --- a/Sources/DevClipCore/Pasteboard/ClipboardMonitor.swift +++ b/Sources/DevClipCore/Pasteboard/ClipboardMonitor.swift @@ -21,7 +21,6 @@ public enum ClipboardMonitorPollResult: Equatable, Sendable { case noChange(changeCount: Int) case ignoredInternalWrite(changeCount: Int) case saved(changeCount: Int, entryCount: Int) - case protectedSecret(changeCount: Int, entryCount: Int) } /// Background actor that polls pasteboard changes and saves new snapshots. @@ -30,7 +29,6 @@ public actor ClipboardMonitor { private let repository: any ClipboardRepository private let writeGuard: ClipboardWriteGuard private let snapshotBuilder: ClipboardSnapshotBuilder - private let ephemeralSensitiveStore: SensitiveEphemeralStore private let options: ClipboardMonitorOptions private var pollingTask: Task? @@ -43,14 +41,12 @@ public actor ClipboardMonitor { repository: any ClipboardRepository, writeGuard: ClipboardWriteGuard = ClipboardWriteGuard(), snapshotBuilder: ClipboardSnapshotBuilder = ClipboardSnapshotBuilder(), - ephemeralSensitiveStore: SensitiveEphemeralStore = SensitiveEphemeralStore(), options: ClipboardMonitorOptions = ClipboardMonitorOptions() ) { self.pasteboardClient = pasteboardClient self.repository = repository self.writeGuard = writeGuard self.snapshotBuilder = snapshotBuilder - self.ephemeralSensitiveStore = ephemeralSensitiveStore self.options = options } @@ -96,11 +92,10 @@ public actor ClipboardMonitor { @discardableResult public func processSnapshot(_ snapshot: ClipboardSnapshot) async throws -> ClipboardMonitorPollResult { _ = try await repository.deleteExpiredEntries(now: Date()) - _ = await ephemeralSensitiveStore.purgeExpired() let buildResult = await snapshotBuilder.build(from: snapshot) - guard !buildResult.entries.isEmpty || !buildResult.protectedEntries.isEmpty else { + guard !buildResult.entries.isEmpty else { return .noChange(changeCount: snapshot.changeCount) } @@ -108,27 +103,13 @@ public actor ClipboardMonitor { return .ignoredInternalWrite(changeCount: snapshot.changeCount) } - if !buildResult.protectedEntries.isEmpty { - await ephemeralSensitiveStore.store( - entries: buildResult.protectedEntries, - representations: buildResult.protectedRepresentations - ) - } - - if !buildResult.entries.isEmpty { - try await repository.save( - group: buildResult.group, - entries: buildResult.entries, - representations: buildResult.representations - ) - - return .saved(changeCount: snapshot.changeCount, entryCount: buildResult.entries.count) - } - - return .protectedSecret( - changeCount: snapshot.changeCount, - entryCount: buildResult.protectedEntries.count + try await repository.save( + group: buildResult.group, + entries: buildResult.entries, + representations: buildResult.representations ) + + return .saved(changeCount: snapshot.changeCount, entryCount: buildResult.entries.count) } private func runPollingLoop() async { diff --git a/Sources/DevClipCore/Pasteboard/ClipboardSnapshotBuilder.swift b/Sources/DevClipCore/Pasteboard/ClipboardSnapshotBuilder.swift index 12a9661..1ed3a24 100644 --- a/Sources/DevClipCore/Pasteboard/ClipboardSnapshotBuilder.swift +++ b/Sources/DevClipCore/Pasteboard/ClipboardSnapshotBuilder.swift @@ -4,42 +4,33 @@ public struct ClipboardSnapshotBuildResult: Equatable, Sendable { public var group: ClipboardGroup public var entries: [ClipboardEntry] public var representations: [ClipboardRepresentation] - public var protectedEntries: [ClipboardEntry] - public var protectedRepresentations: [ClipboardRepresentation] public var snapshotHash: String public init( group: ClipboardGroup, entries: [ClipboardEntry], representations: [ClipboardRepresentation], - protectedEntries: [ClipboardEntry] = [], - protectedRepresentations: [ClipboardRepresentation] = [], snapshotHash: String ) { self.group = group self.entries = entries self.representations = representations - self.protectedEntries = protectedEntries - self.protectedRepresentations = protectedRepresentations self.snapshotHash = snapshotHash } } -/// Converts pasteboard snapshots into model records with type and sensitivity metadata. +/// Converts pasteboard snapshots into model records with type metadata. public struct ClipboardSnapshotBuilder: Sendable { private let contentClassifier: any ContentClassifier - private let sensitiveDetector: any SensitiveContentDetecting private let blobStore: (any BlobStore)? private let thumbnailGenerator: any ImageThumbnailGenerating public init( contentClassifier: any ContentClassifier = DefaultContentClassifier(), - sensitiveDetector: any SensitiveContentDetecting = DefaultSensitiveContentDetector(), blobStore: (any BlobStore)? = nil, thumbnailGenerator: any ImageThumbnailGenerating = AppKitImageThumbnailGenerator() ) { self.contentClassifier = contentClassifier - self.sensitiveDetector = sensitiveDetector self.blobStore = blobStore self.thumbnailGenerator = thumbnailGenerator } @@ -48,8 +39,6 @@ public struct ClipboardSnapshotBuilder: Sendable { let groupID = UUID() var entries: [ClipboardEntry] = [] var representations: [ClipboardRepresentation] = [] - var protectedEntries: [ClipboardEntry] = [] - var protectedRepresentations: [ClipboardRepresentation] = [] for item in snapshot.items { guard !item.representations.isEmpty else { @@ -61,17 +50,9 @@ public struct ClipboardSnapshotBuilder: Sendable { let contentHash = ClipboardContentHasher.hash(item: item) let classificationInput = bestClassificationInput(in: item) let classification = await classify(input: classificationInput) - let sensitivity = await detectSensitivity( - input: classificationInput, - sourceBundleIdentifier: snapshot.sourceBundleIdentifier - ) let detectedKind = classification.detectedKind let previewText = preview(from: extractedText, item: item) - let redactedPreview = redactedPreview( - original: previewText, - sensitivity: sensitivity.classification - ) - let title = title(from: redactedPreview, detectedKind: detectedKind) + let title = title(from: previewText, detectedKind: detectedKind) let byteCount = item.representations.reduce(Int64(0)) { partial, representation in partial + Int64(representation.data.count) } @@ -79,8 +60,7 @@ public struct ClipboardSnapshotBuilder: Sendable { var metadata = metadata( snapshot: snapshot, item: item, - classification: classification, - sensitivity: sensitivity + classification: classification ) for (key, value) in representationBuild.metadata.values { metadata.values[key] = value @@ -94,26 +74,18 @@ public struct ClipboardSnapshotBuilder: Sendable { sourceAppName: snapshot.sourceAppName, sourceBundleIdentifier: snapshot.sourceBundleIdentifier, contentHash: contentHash, - searchableText: searchableText( - original: extractedText ?? previewText, - sensitivity: sensitivity.classification - ), - previewText: redactedPreview, + searchableText: extractedText ?? previewText, + previewText: previewText, createdAt: snapshot.capturedAt, updatedAt: snapshot.capturedAt, - isSensitive: sensitivity.classification != .none, - expiresAt: sensitivity.expiresAt, + isSensitive: false, + expiresAt: nil, byteCount: byteCount, metadata: metadata ) - if sensitivity.shouldPersist { - entries.append(entry) - representations.append(contentsOf: representationBuild.representations) - } else if sensitivity.shouldRetainInMemory { - protectedEntries.append(entry) - protectedRepresentations.append(contentsOf: representationBuild.representations) - } + entries.append(entry) + representations.append(contentsOf: representationBuild.representations) } let group = ClipboardGroup( @@ -132,8 +104,6 @@ public struct ClipboardSnapshotBuilder: Sendable { group: group, entries: entries, representations: representations, - protectedEntries: protectedEntries, - protectedRepresentations: protectedRepresentations, snapshotHash: ClipboardContentHasher.hash(snapshot: snapshot) ) } @@ -155,26 +125,6 @@ public struct ClipboardSnapshotBuilder: Sendable { } } - private func detectSensitivity( - input: ClassificationInput, - sourceBundleIdentifier: String? - ) async -> SensitiveDetectionResult { - do { - return try await sensitiveDetector.detect( - input, - sourceBundleIdentifier: sourceBundleIdentifier - ) - } catch { - return SensitiveDetectionResult( - classification: .potential, - evidence: ["detector_error"], - expiresAt: Date().addingTimeInterval(10 * 60), - shouldIndex: true, - shouldPersist: true - ) - } - } - private struct RepresentationBuild: Sendable { var representations: [ClipboardRepresentation] var metadata: ClipboardMetadata @@ -379,34 +329,6 @@ public struct ClipboardSnapshotBuilder: Sendable { return .binary } - private func searchableText( - original: String, - sensitivity: SensitiveClassification - ) -> String { - switch sensitivity { - case .none: - original - case .potential: - "可能敏感内容已遮罩" - case .secret: - "敏感内容已遮罩" - } - } - - private func redactedPreview( - original: String, - sensitivity: SensitiveClassification - ) -> String { - switch sensitivity { - case .none: - original - case .potential: - "可能敏感内容已遮罩" - case .secret: - "敏感内容已遮罩" - } - } - private func preview(from extractedText: String?, item: PasteboardItemSnapshot) -> String { if let extractedText, !extractedText.isEmpty { return clipped(extractedText.replacingOccurrences(of: "\n", with: " "), maxLength: 500) @@ -422,10 +344,9 @@ public struct ClipboardSnapshotBuilder: Sendable { private func metadata( snapshot: ClipboardSnapshot, item: PasteboardItemSnapshot, - classification: ClassificationResult, - sensitivity: SensitiveDetectionResult + classification: ClassificationResult ) -> ClipboardMetadata { - var values: [String: String] = [ + let values: [String: String] = [ "phase": "4", "representationTypes": representationTypes(in: item), "snapshotChangeCount": String(snapshot.changeCount), @@ -440,15 +361,9 @@ public struct ClipboardSnapshotBuilder: Sendable { .filter { $0.kind != classification.detectedKind } .map(\.kind.rawValue) .joined(separator: ","), - "sensitiveClassification": sensitivity.classification.rawValue, - "sensitiveEvidence": sensitivity.evidence.joined(separator: ","), - "shouldIndex": sensitivity.shouldIndex ? "true" : "false" + "shouldIndex": "true" ] - if sensitivity.classification != .none { - values["isMasked"] = "true" - } - return ClipboardMetadata(values: values) } diff --git a/Sources/DevClipCore/Search/SearchQuery.swift b/Sources/DevClipCore/Search/SearchQuery.swift index 9562e64..e575fdc 100644 --- a/Sources/DevClipCore/Search/SearchQuery.swift +++ b/Sources/DevClipCore/Search/SearchQuery.swift @@ -4,7 +4,6 @@ public enum SearchFilter: Equatable, Sendable { case type(ClipboardContentKind) case app(String) case pinned(Bool) - case sensitive(Bool) case before(Date) case after(Date) case tag(String) diff --git a/Sources/DevClipCore/Search/SearchQueryParser.swift b/Sources/DevClipCore/Search/SearchQueryParser.swift index a28d824..309a095 100644 --- a/Sources/DevClipCore/Search/SearchQueryParser.swift +++ b/Sources/DevClipCore/Search/SearchQueryParser.swift @@ -108,12 +108,6 @@ public struct SearchQueryParser: SearchQueryParsing { case "unpinned": query.filters.append(.pinned(false)) return true - case "sensitive": - query.filters.append(.sensitive(true)) - return true - case "public": - query.filters.append(.sensitive(false)) - return true default: return false } diff --git a/Sources/DevClipCore/Search/SearchService.swift b/Sources/DevClipCore/Search/SearchService.swift index f977dfb..0fb68d2 100644 --- a/Sources/DevClipCore/Search/SearchService.swift +++ b/Sources/DevClipCore/Search/SearchService.swift @@ -120,11 +120,6 @@ public actor SQLiteSearchService: SearchService { return false } - case let .sensitive(isSensitive): - guard entry.isSensitive == isSensitive else { - return false - } - case let .before(date): guard entry.createdAt < date else { return false diff --git a/Sources/DevClipCore/Security/SensitiveContentDetector.swift b/Sources/DevClipCore/Security/SensitiveContentDetector.swift deleted file mode 100644 index b51a3dd..0000000 --- a/Sources/DevClipCore/Security/SensitiveContentDetector.swift +++ /dev/null @@ -1,226 +0,0 @@ -import Foundation - -public struct SensitiveDetectionResult: Equatable, Sendable { - public var classification: SensitiveClassification - public var evidence: [String] - public var expiresAt: Date? - public var shouldIndex: Bool - public var shouldPersist: Bool - public var shouldRetainInMemory: Bool - - public init( - classification: SensitiveClassification, - evidence: [String] = [], - expiresAt: Date? = nil, - shouldIndex: Bool = true, - shouldPersist: Bool = true, - shouldRetainInMemory: Bool = false - ) { - self.classification = classification - self.evidence = evidence - self.expiresAt = expiresAt - self.shouldIndex = shouldIndex - self.shouldPersist = shouldPersist - self.shouldRetainInMemory = shouldRetainInMemory - } -} - -public protocol SensitiveContentDetecting: Sendable { - func detect(_ input: ClassificationInput, sourceBundleIdentifier: String?) async throws -> SensitiveDetectionResult -} - -public struct IgnoredSourceApplicationPolicy: Equatable, Sendable { - public var ignoredBundleIdentifiers: Set - public var ignoredBundleIdentifierFragments: Set - - public init( - ignoredBundleIdentifiers: Set = Self.defaultBundleIdentifiers, - ignoredBundleIdentifierFragments: Set = Self.defaultBundleIdentifierFragments - ) { - self.ignoredBundleIdentifiers = ignoredBundleIdentifiers - self.ignoredBundleIdentifierFragments = ignoredBundleIdentifierFragments - } - - public func shouldIgnore(bundleIdentifier: String?) -> Bool { - guard let bundleIdentifier else { - return false - } - - let normalized = bundleIdentifier.lowercased() - if ignoredBundleIdentifiers.contains(normalized) { - return true - } - - return ignoredBundleIdentifierFragments.contains { normalized.contains($0) } - } - - public static let defaultBundleIdentifiers: Set = [ - "com.apple.keychainaccess", - "com.apple.passwords", - "com.1password.1password", - "com.agilebits.onepassword7", - "com.bitwarden.desktop", - "org.keepassxc.keepassxc", - "com.lastpass.lastpassmacdesktop" - ] - - public static let defaultBundleIdentifierFragments: Set = [ - "1password", - "bitwarden", - "lastpass", - "keepass", - "dashlane", - "password", - "keychain" - ] -} - -/// Offline detector for tokens, keys, passwords, and high-risk source apps. -public struct DefaultSensitiveContentDetector: SensitiveContentDetecting { - private let ignoredSourcePolicy: IgnoredSourceApplicationPolicy - private let clock: @Sendable () -> Date - - public init( - ignoredSourcePolicy: IgnoredSourceApplicationPolicy = IgnoredSourceApplicationPolicy(), - clock: @escaping @Sendable () -> Date = Date.init - ) { - self.ignoredSourcePolicy = ignoredSourcePolicy - self.clock = clock - } - - public func detect( - _ input: ClassificationInput, - sourceBundleIdentifier: String? - ) async throws -> SensitiveDetectionResult { - if ignoredSourcePolicy.shouldIgnore(bundleIdentifier: sourceBundleIdentifier) { - return secretResult( - evidence: ["source_app_ignored"], - retainInMemory: false - ) - } - - guard let text = String(data: input.data, encoding: .utf8) else { - return SensitiveDetectionResult(classification: .none) - } - - let evidence = Self.findEvidence(in: text) - if evidence.contains(where: Self.isSecretEvidence) { - return secretResult(evidence: evidence.filter(Self.isSecretEvidence)) - } - - let potentialEvidence = evidence.filter { !Self.isSecretEvidence($0) } - if !potentialEvidence.isEmpty { - return potentialResult(evidence: potentialEvidence) - } - - return SensitiveDetectionResult(classification: .none) - } - - private func potentialResult(evidence: [String]) -> SensitiveDetectionResult { - SensitiveDetectionResult( - classification: .potential, - evidence: evidence, - expiresAt: clock().addingTimeInterval(10 * 60), - shouldIndex: true, - shouldPersist: true, - shouldRetainInMemory: false - ) - } - - private func secretResult( - evidence: [String], - retainInMemory: Bool = true - ) -> SensitiveDetectionResult { - SensitiveDetectionResult( - classification: .secret, - evidence: evidence, - expiresAt: clock().addingTimeInterval(60), - shouldIndex: false, - shouldPersist: false, - shouldRetainInMemory: retainInMemory - ) - } - - private static func findEvidence(in text: String) -> [String] { - var evidence: [String] = [] - - appendEvidence("pem_private_key", to: &evidence, if: text.matches(#"-----BEGIN [A-Z ]*PRIVATE KEY-----"#)) - appendEvidence("bearer_token", to: &evidence, if: text.matches(#"(?i)\bBearer\s+[A-Za-z0-9._~+/\-]+=*"#)) - appendEvidence("jwt", to: &evidence, if: text.matches(#"\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"#)) - appendEvidence("aws_access_key", to: &evidence, if: text.matches(#"\b(AKIA|ASIA)[0-9A-Z]{16}\b"#)) - appendEvidence("github_token", to: &evidence, if: text.matches(#"\b(gh[pousr]_[A-Za-z0-9_]{30,}|github_pat_[A-Za-z0-9_]{20,})\b"#)) - appendEvidence("database_connection_secret", to: &evidence, if: text.matches(#"[A-Za-z][A-Za-z0-9+.-]*://[^:\s/@]+:[^@\s/]+@"#)) - appendEvidence("named_secret_assignment", to: &evidence, if: text.matches(#"(?i)\b(api[_-]?key|secret|token|password|passwd|pwd|client_secret)\b\s*[:=]\s*['"]?[A-Za-z0-9._~+/\-=$]{8,}"#)) - appendEvidence("env_secret", to: &evidence, if: text.matches(#"(?im)^[A-Z0-9_]*(SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY)[A-Z0-9_]*=.+"#)) - appendEvidence("verification_code", to: &evidence, if: text.matches(#"(?i)\b(code|验证码|verification)\D{0,12}\d{4,8}\b"#) || text.matches(#"^\d{6}$"#)) - - if hasHighEntropyToken(in: text) { - evidence.append("high_entropy_string") - } - - return Array(Set(evidence)).sorted() - } - - private static func appendEvidence( - _ value: String, - to evidence: inout [String], - if condition: Bool - ) { - if condition { - evidence.append(value) - } - } - - private static func isSecretEvidence(_ evidence: String) -> Bool { - [ - "aws_access_key", - "bearer_token", - "database_connection_secret", - "env_secret", - "github_token", - "jwt", - "named_secret_assignment", - "pem_private_key" - ].contains(evidence) - } - - private static func hasHighEntropyToken(in text: String) -> Bool { - let tokens = text - .split { character in - character.isWhitespace || character == "\"" || character == "'" || character == "`" - } - .map(String.init) - - return tokens.contains { token in - let trimmed = token.trimmingCharacters(in: CharacterSet(charactersIn: ",;()[]{}<>")) - guard trimmed.count >= 32 else { - return false - } - - return entropy(trimmed) >= 4.25 - } - } - - private static func entropy(_ value: String) -> Double { - let scalars = Array(value.unicodeScalars) - guard !scalars.isEmpty else { - return 0 - } - - let counts = Dictionary(grouping: scalars, by: { $0 }).mapValues(\.count) - let length = Double(scalars.count) - - return counts.values.reduce(0) { partial, count in - let probability = Double(count) / length - return partial - probability * log2(probability) - } - } -} - -public typealias PlaceholderSensitiveContentDetector = DefaultSensitiveContentDetector - -private extension String { - func matches(_ pattern: String) -> Bool { - range(of: pattern, options: .regularExpression) != nil - } -} diff --git a/Sources/DevClipCore/Security/SensitiveEphemeralStore.swift b/Sources/DevClipCore/Security/SensitiveEphemeralStore.swift deleted file mode 100644 index 67c6d06..0000000 --- a/Sources/DevClipCore/Security/SensitiveEphemeralStore.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation - -public struct SensitiveEphemeralRecord: Equatable, Sendable { - public var entry: ClipboardEntry - public var representations: [ClipboardRepresentation] - public var expiresAt: Date - - public init( - entry: ClipboardEntry, - representations: [ClipboardRepresentation], - expiresAt: Date - ) { - self.entry = entry - self.representations = representations - self.expiresAt = expiresAt - } -} - -/// In-memory retention for secret entries that must not be persisted. -public actor SensitiveEphemeralStore { - private var recordsByEntryID: [UUID: SensitiveEphemeralRecord] = [:] - - public init() {} - - public func store( - entries: [ClipboardEntry], - representations: [ClipboardRepresentation], - defaultLifetime: TimeInterval = 60 - ) { - let representationsByEntryID = Dictionary(grouping: representations, by: \.entryID) - let now = Date() - - for entry in entries { - let expiry = entry.expiresAt ?? now.addingTimeInterval(defaultLifetime) - recordsByEntryID[entry.id] = SensitiveEphemeralRecord( - entry: entry, - representations: representationsByEntryID[entry.id] ?? [], - expiresAt: expiry - ) - } - } - - @discardableResult - public func purgeExpired(now: Date = Date()) -> Int { - let expiredIDs = recordsByEntryID - .filter { $0.value.expiresAt <= now } - .map(\.key) - - for id in expiredIDs { - recordsByEntryID.removeValue(forKey: id) - } - - return expiredIDs.count - } - - public func records(now: Date = Date()) -> [SensitiveEphemeralRecord] { - recordsByEntryID.values - .filter { $0.expiresAt > now } - .sorted { $0.entry.createdAt < $1.entry.createdAt } - } -} diff --git a/Sources/DevClipCore/Support/DependencyContainer.swift b/Sources/DevClipCore/Support/DependencyContainer.swift index 2f7f670..af0147f 100644 --- a/Sources/DevClipCore/Support/DependencyContainer.swift +++ b/Sources/DevClipCore/Support/DependencyContainer.swift @@ -7,9 +7,7 @@ public struct DependencyContainer: Sendable { public var repository: any ClipboardRepository public var writeGuard: ClipboardWriteGuard public var clipboardMonitor: ClipboardMonitor - public var ephemeralSensitiveStore: SensitiveEphemeralStore public var contentClassifier: any ContentClassifier - public var sensitiveDetector: any SensitiveContentDetecting public var transformEngine: TransformEngine public var searchService: any SearchService public var blobStore: any BlobStore @@ -33,9 +31,7 @@ public struct DependencyContainer: Sendable { repository: any ClipboardRepository, writeGuard: ClipboardWriteGuard, clipboardMonitor: ClipboardMonitor, - ephemeralSensitiveStore: SensitiveEphemeralStore, contentClassifier: any ContentClassifier, - sensitiveDetector: any SensitiveContentDetecting, transformEngine: TransformEngine, searchService: any SearchService, blobStore: any BlobStore, @@ -58,9 +54,7 @@ public struct DependencyContainer: Sendable { self.repository = repository self.writeGuard = writeGuard self.clipboardMonitor = clipboardMonitor - self.ephemeralSensitiveStore = ephemeralSensitiveStore self.contentClassifier = contentClassifier - self.sensitiveDetector = sensitiveDetector self.transformEngine = transformEngine self.searchService = searchService self.blobStore = blobStore @@ -84,8 +78,6 @@ public struct DependencyContainer: Sendable { let pasteboardClient = SystemPasteboardClient() let writeGuard = ClipboardWriteGuard() let contentClassifier = DefaultContentClassifier() - let sensitiveDetector = DefaultSensitiveContentDetector() - let ephemeralSensitiveStore = SensitiveEphemeralStore() let transformEngine = TransformEngine() var blobStore: FileSystemBlobStore @@ -128,15 +120,13 @@ public struct DependencyContainer: Sendable { let snapshotBuilder = ClipboardSnapshotBuilder( contentClassifier: contentClassifier, - sensitiveDetector: sensitiveDetector, blobStore: blobStore ) let monitor = ClipboardMonitor( pasteboardClient: pasteboardClient, repository: repository, writeGuard: writeGuard, - snapshotBuilder: snapshotBuilder, - ephemeralSensitiveStore: ephemeralSensitiveStore + snapshotBuilder: snapshotBuilder ) let pasteEngine = PasteEngine( repository: repository, @@ -156,9 +146,7 @@ public struct DependencyContainer: Sendable { repository: repository, writeGuard: writeGuard, clipboardMonitor: monitor, - ephemeralSensitiveStore: ephemeralSensitiveStore, contentClassifier: contentClassifier, - sensitiveDetector: sensitiveDetector, transformEngine: transformEngine, searchService: searchService, blobStore: blobStore, diff --git a/Tests/DevClipCoreTests/Phase3QuickPanelSearchTests.swift b/Tests/DevClipCoreTests/Phase3QuickPanelSearchTests.swift index ccb26e6..9a3825b 100644 --- a/Tests/DevClipCoreTests/Phase3QuickPanelSearchTests.swift +++ b/Tests/DevClipCoreTests/Phase3QuickPanelSearchTests.swift @@ -9,7 +9,7 @@ struct Phase3QuickPanelSearchTests { let parser = SearchQueryParser() let query = try parser.parse( - #""exact phrase" type:json app:"Visual Studio Code" is:pinned is:sensitive before:2026-06-01 after:2026-01-01 #api token"# + #""exact phrase" type:json app:"Visual Studio Code" is:pinned before:2026-06-01 after:2026-01-01 #api token"# ) #expect(query.terms == ["token"]) @@ -17,7 +17,6 @@ struct Phase3QuickPanelSearchTests { #expect(query.filters.contains(.type(.json))) #expect(query.filters.contains(.app("Visual Studio Code"))) #expect(query.filters.contains(.pinned(true))) - #expect(query.filters.contains(.sensitive(true))) #expect(query.filters.contains(.tag("api"))) #expect(query.filters.contains { filter in if case .before = filter { @@ -67,8 +66,7 @@ struct Phase3QuickPanelSearchTests { sourceAppName: "Terminal", sourceBundleIdentifier: "com.apple.Terminal", searchableText: #"{"hello":"world"}"#, - contentHash: "sha256:phase3-json", - isSensitive: true + contentHash: "sha256:phase3-json" ) try await repository.save(group: group, entries: [sourceEntry, jsonEntry], representations: []) @@ -203,7 +201,6 @@ private func makeEntry( searchableText: String, contentHash: String, isPinned: Bool = false, - isSensitive: Bool = false, metadata: ClipboardMetadata = ClipboardMetadata() ) -> ClipboardEntry { ClipboardEntry( @@ -219,7 +216,6 @@ private func makeEntry( updatedAt: Date(timeIntervalSince1970: 10), copyCount: 1, isPinned: isPinned, - isSensitive: isSensitive, byteCount: Int64(searchableText.utf8.count), metadata: metadata ) diff --git a/Tests/DevClipCoreTests/Phase4ClassificationSecurityTests.swift b/Tests/DevClipCoreTests/Phase4ClassificationSecurityTests.swift index 2cb8849..6b6ffb6 100644 --- a/Tests/DevClipCoreTests/Phase4ClassificationSecurityTests.swift +++ b/Tests/DevClipCoreTests/Phase4ClassificationSecurityTests.swift @@ -72,106 +72,28 @@ struct Phase4ClassificationSecurityTests { } @Test - func sensitiveDetectorClassifiesSecretTokenWithoutPersistingOrIndexing() async throws { - let now = Date(timeIntervalSince1970: 100) - let detector = DefaultSensitiveContentDetector(clock: { now }) - - let result = try await detector.detect( - ClassificationInput(data: Data("Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456".utf8)), - sourceBundleIdentifier: "com.apple.Terminal" - ) - - #expect(result.classification == .secret) - #expect(result.evidence.contains("bearer_token")) - #expect(result.expiresAt == now.addingTimeInterval(60)) - #expect(!result.shouldIndex) - #expect(!result.shouldPersist) - #expect(result.shouldRetainInMemory) - } - - @Test - func sensitiveDetectorClassifiesVerificationCodeAsPotential() async throws { - let now = Date(timeIntervalSince1970: 200) - let detector = DefaultSensitiveContentDetector(clock: { now }) - - let result = try await detector.detect( - ClassificationInput(data: Data("验证码 123456".utf8)), - sourceBundleIdentifier: "com.apple.MobileSMS" - ) - - #expect(result.classification == .potential) - #expect(result.evidence.contains("verification_code")) - #expect(result.expiresAt == now.addingTimeInterval(10 * 60)) - #expect(result.shouldIndex) - #expect(result.shouldPersist) - } - - @Test - func sensitiveDetectorIgnoresPasswordManagerSources() async throws { - let detector = DefaultSensitiveContentDetector() - - let result = try await detector.detect( - ClassificationInput(data: Data("ordinary copied text".utf8)), - sourceBundleIdentifier: "com.1password.1password" - ) - - #expect(result.classification == .secret) - #expect(result.evidence == ["source_app_ignored"]) - #expect(!result.shouldPersist) - #expect(!result.shouldRetainInMemory) - } - - @Test - func monitorStoresSecretOnlyInEphemeralMemory() async throws { - let repository = InMemoryClipboardRepository() - let ephemeralStore = SensitiveEphemeralStore() - let monitor = ClipboardMonitor( - pasteboardClient: EmptyPasteboardClient(), - repository: repository, - writeGuard: ClipboardWriteGuard(persistMarkers: false), - ephemeralSensitiveStore: ephemeralStore - ) - let privateKey = """ - -----BEGIN PRIVATE KEY----- - abcdefghijklmnopqrstuvwxyz - -----END PRIVATE KEY----- - """ - - let result = try await monitor.processSnapshot( - makeSnapshot(changeCount: 401, text: privateKey) - ) - - let persistedEntries = try await repository.entries() - let ephemeralRecords = await ephemeralStore.records() - - #expect(result == .protectedSecret(changeCount: 401, entryCount: 1)) - #expect(persistedEntries.isEmpty) - #expect(ephemeralRecords.count == 1) - #expect(ephemeralRecords.first?.entry.isSensitive == true) - #expect(ephemeralRecords.first?.entry.metadata.values["shouldIndex"] == "false") - } - - @Test - func monitorMasksPotentialSensitiveContentAndSetsExpiry() async throws { + func monitorPersistsTokenLikeContentWithoutMasking() async throws { let repository = InMemoryClipboardRepository() let monitor = ClipboardMonitor( pasteboardClient: EmptyPasteboardClient(), repository: repository, writeGuard: ClipboardWriteGuard(persistMarkers: false) ) + let token = "Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456" let result = try await monitor.processSnapshot( - makeSnapshot(changeCount: 402, text: "验证码 123456") + makeSnapshot(changeCount: 401, text: token) ) + let entries = try await repository.entries() - #expect(result == .saved(changeCount: 402, entryCount: 1)) + #expect(result == .saved(changeCount: 401, entryCount: 1)) #expect(entries.count == 1) - #expect(entries.first?.isSensitive == true) - #expect(entries.first?.previewText == "可能敏感内容已遮罩") - #expect(entries.first?.searchableText == "可能敏感内容已遮罩") - #expect(entries.first?.expiresAt != nil) - #expect(entries.first?.metadata.values["sensitiveClassification"] == "potential") + #expect(entries.first?.isSensitive == false) + #expect(entries.first?.previewText == token) + #expect(entries.first?.searchableText == token) + #expect(entries.first?.expiresAt == nil) + #expect(entries.first?.metadata.values["sensitiveClassification"] == nil) } @Test diff --git a/Tests/DevClipCoreTests/Phase8ExportSettingsPerformanceTests.swift b/Tests/DevClipCoreTests/Phase8ExportSettingsPerformanceTests.swift index e89b5fd..99f9cee 100644 --- a/Tests/DevClipCoreTests/Phase8ExportSettingsPerformanceTests.swift +++ b/Tests/DevClipCoreTests/Phase8ExportSettingsPerformanceTests.swift @@ -5,7 +5,7 @@ import Testing @Suite("Phase 8 Export Settings Performance Tests") struct Phase8ExportSettingsPerformanceTests { @Test - func encryptedExportExcludesSensitiveEntriesAndImportsRoundTrip() async throws { + func encryptedExportIncludesAllEntriesAndImportsRoundTrip() async throws { let sourceRepository = InMemoryClipboardRepository() let targetRepository = InMemoryClipboardRepository() let sourceService = AESGCMClipboardArchiveService( @@ -23,11 +23,9 @@ struct Phase8ExportSettingsPerformanceTests { ) let secretEntry = makePhase8Entry( groupID: group.id, - title: "敏感记录", + title: "令牌记录", text: "SECRET_TOKEN=abc123456789", - hash: "sha256:phase8-secret", - isSensitive: true, - metadata: ClipboardMetadata(values: ["sensitiveClassification": SensitiveClassification.secret.rawValue]) + hash: "sha256:phase8-secret" ) try await sourceRepository.save( @@ -43,8 +41,8 @@ struct Phase8ExportSettingsPerformanceTests { let encodedArchive = try JSONEncoder().encode(exportResult.archive) let encodedArchiveText = String(data: encodedArchive, encoding: .utf8) ?? "" - #expect(exportResult.summary.exportedEntryCount == 1) - #expect(exportResult.summary.skippedSensitiveCount == 1) + #expect(exportResult.summary.exportedEntryCount == 2) + #expect(exportResult.summary.skippedEntryCount == 0) #expect(!encodedArchiveText.contains("SECRET_TOKEN")) let importSummary = try await targetService.importEncrypted( @@ -53,9 +51,10 @@ struct Phase8ExportSettingsPerformanceTests { ) let importedEntries = try await targetRepository.entries() - #expect(importSummary.importedEntryCount == 1) - #expect(importedEntries.map(\.title) == ["安全记录"]) + #expect(importSummary.importedEntryCount == 2) + #expect(importedEntries.map(\.title).sorted() == ["令牌记录", "安全记录"]) #expect(try await targetRepository.representations(entryID: safeEntry.id).first?.inlineData == Data("safe payload".utf8)) + #expect(try await targetRepository.representations(entryID: secretEntry.id).first?.inlineData == Data("SECRET_TOKEN=abc123456789".utf8)) } @Test @@ -157,7 +156,6 @@ private func makePhase8Entry( title: String, text: String, hash: String, - isSensitive: Bool = false, metadata: ClipboardMetadata = ClipboardMetadata() ) -> ClipboardEntry { ClipboardEntry( @@ -171,7 +169,6 @@ private func makePhase8Entry( previewText: text, createdAt: Date(timeIntervalSince1970: 80), updatedAt: Date(timeIntervalSince1970: 80), - isSensitive: isSensitive, byteCount: Int64(text.utf8.count), metadata: metadata ) diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 52661d1..516d5b2 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -22,6 +22,11 @@ Phase 8:设置、导入导出、性能优化和发布准备。 - 稳定 `ClipboardWriteGuard` 测试: - 生产默认仍持久化内部写入标记。 - 单元测试可关闭 UserDefaults 持久化,避免跨测试运行污染。 +- 按本地个人使用场景移除敏感内容遮罩功能: + - 新采集内容不再检测 API Key、Token、私钥或验证码并自动遮罩。 + - 不再将 secret 内容分流到内存,也不再因为敏感分类跳过持久化或全文索引。 + - 历史窗口移除“敏感”筛选和徽标,设置页移除遮罩开关。 + - 加密导出不再按敏感标记跳过记录,仅保留 `shouldExport=false` 的手动排除能力。 ## 已完成 @@ -45,14 +50,12 @@ Phase 8:设置、导入导出、性能优化和发布准备。 - `TransformPipeline` - `TransformExecution` - `ClipboardStack` - - `SensitiveClassification` - 创建协议和明确占位实现: - `PasteboardClient` - `ClipboardMonitor` - `ClipboardWriteGuard` - `ClipboardRepository` - `ContentClassifier` - - `SensitiveContentDetecting` - `TransformAction` - `TransformEngine` - `SearchService` @@ -145,7 +148,7 @@ Phase 8:设置、导入导出、性能优化和发布准备。 - 实现 `SearchQueryParser`: - 支持普通关键词。 - 支持精确短语。 - - 支持 `type:`、`app:`、`is:pinned`、`is:sensitive`、`before:`、`after:` 和 `#tag`。 + - 支持 `type:`、`app:`、`is:pinned`、`before:`、`after:` 和 `#tag`。 - 未识别过滤器退回普通关键词。 - 实现 `SQLiteSearchService`: - 长查询优先使用 FTS5。 @@ -226,38 +229,12 @@ Phase 8:设置、导入导出、性能优化和发布准备。 - `image` - `fileList` - `binary` -- 实现 `DefaultSensitiveContentDetector`: - - 检测 PEM Private Key。 - - 检测 Bearer Token。 - - 检测 JWT。 - - 检测常见 API Key/Secret/Token/Password 赋值。 - - 检测 AWS Access Key。 - - 检测 GitHub Token。 - - 检测数据库连接串用户名密码。 - - 检测 `.env` 密钥。 - - 检测高熵长字符串。 - - 检测验证码。 - - 检测密码管理器和钥匙串来源应用。 -- 实现敏感内容策略: - - `none` 正常保存。 - - `potential` 默认遮罩,设置 10 分钟过期,允许持久化。 - - `secret` 默认不持久化、不进入 FTS,只保留在内存并设置 60 秒过期。 - - 忽略来源应用默认不持久化,也不进入短期内存缓存。 - - 敏感元数据只记录证据标签,不记录完整剪贴板正文。 -- 实现 `IgnoredSourceApplicationPolicy`: - - 默认包含 Keychain Access、Passwords、1Password、Bitwarden、KeePassXC、LastPass。 - - 支持通过 bundle identifier 集合和片段集合扩展忽略规则。 -- 实现 `SensitiveEphemeralStore`: - - actor 隔离。 - - 保存 secret 记录和表示到内存。 - - 默认 60 秒清理。 -- 将 Phase 4 接入采集链路: +- 将 Phase 4 内容分类接入采集链路: - `ClipboardSnapshotBuilder` 改为异步构建。 - - 每个 pasteboard item 先分类,再做敏感检测。 + - 每个 pasteboard item 先进行类型分类。 - `ClipboardEntry.detectedKind` 使用分类结果。 - - `ClipboardEntry.metadata` 保存候选类型、证据标签、敏感等级和索引策略。 - - potential/secret 预览和 searchable text 默认遮罩。 - - secret 从持久化 entries 中分离到 protected entries。 + - `ClipboardEntry.metadata` 保存候选类型、分类证据和索引策略。 + - 本地个人版不做敏感内容遮罩,复制内容按原文持久化、预览和索引。 - 扩展 Repository 过期清理: - `ClipboardRepository.deleteExpiredEntries(now:)`。 - `InMemoryClipboardRepository` 清理过期 entry。 @@ -269,11 +246,7 @@ Phase 8:设置、导入导出、性能优化和发布准备。 - 内容分类覆盖测试。 - 图片和文件列表分类测试。 - Detector 抛错不中断测试。 - - secret token 检测测试。 - - 验证码 potential 策略测试。 - - 密码管理器来源忽略测试。 - - secret 只进内存不持久化测试。 - - potential 遮罩和过期测试。 + - token 样式内容完整保存且不遮罩测试。 - Repository 过期清理测试。 - `shouldIndex=false` 不进入 FTS 测试。 - 实现 Phase 5 `TransformEngine`: @@ -440,7 +413,6 @@ Phase 8:设置、导入导出、性能优化和发布准备。 - Snippet 转 TransformInput 测试。 - 完成 Phase 8 设置窗口: - 通用设置支持开机启动和自动粘贴。 - - 隐私设置保留敏感内容遮罩开关。 - 导入导出设置支持输入口令、导出路径、导入路径、加密导出和解密导入。 - 维护设置显示 Sparkle 2 预留接口状态,并支持测量当前库搜索耗时。 - 设置页用户可见文案使用中文。 @@ -453,7 +425,7 @@ Phase 8:设置、导入导出、性能优化和发布准备。 - 新增 `AESGCMClipboardArchiveService`。 - 使用 CryptoKit AES-GCM 加密导出包。 - 使用 HKDF-SHA256 从用户口令和随机 salt 派生 AES 密钥。 - - 导出包默认排除 `isSensitive == true`、metadata 标记 secret、metadata 标记 `shouldExport=false` 的记录。 + - 导出包默认包含所有普通历史记录,仅排除 metadata 标记 `shouldExport=false` 的记录。 - Blob 文件引用不直接塞进主导出包,避免复制外部 Blob 文件正文。 - 新增 `ClipboardArchiveFileClient` 和 JSON 文件实现。 - 验证 Sparkle 2 预留接口: @@ -469,7 +441,7 @@ Phase 8:设置、导入导出、性能优化和发布准备。 - 输出 codesign 和 Gatekeeper 状态。 - 明确本地构建为 ad-hoc 签名,正式分发仍需 Developer ID、Hardened Runtime 和 Notarization。 - 新增 Phase 8 测试: - - AES-GCM 导出排除敏感记录并可解密导入测试。 + - AES-GCM 导出包含 token 样式记录并可解密导入测试。 - 错误口令导入失败测试。 - 归档 JSON 文件读写测试。 - 开机启动 Mock 测试。 @@ -482,7 +454,7 @@ Phase 8:设置、导入导出、性能优化和发布准备。 - Quick Panel Action Panel 接入 `TransformEngine.smartActions`。 - Action Panel 支持转换预览,Command+Return 可保存派生记录并写回剪贴板。 - Quick Panel Command+D 接入 Diff 选择和预览。 - - 完整历史管理窗口替换占位界面,支持搜索、固定、敏感筛选、详情预览、复制、纯文本粘贴和删除。 + - 完整历史管理窗口替换占位界面,支持搜索、固定、详情预览、复制、纯文本粘贴和删除。 - 主要窗口接入统一的 DevClip 工作台视觉背景和面板风格。 - 生成并接入 DevClip app icon,SwiftPM 资源和本地 app bundle 均包含图标。 - 新增 Blob 读取、图片缩略图和 Blob 写回测试。 @@ -501,7 +473,7 @@ Phase 8:设置、导入导出、性能优化和发布准备。 - Sparkle 2 只预留 `UpdateCheckingClient`,暂不引入 Sparkle 依赖。 - KeyboardShortcuts 钉定为 1.9.4。原因:1.17.0 在当前 Command Line Tools 环境下编译 `#Preview` 时缺少 `PreviewsMacros`。 - Phase 1 的去重放在 Repository 层,以保证无论 snapshot 来自真实 NSPasteboard 还是 Mock,都按相同 content hash 规则合并。 -- Phase 4 已将 `ClipboardSnapshotBuilder` 升级为异步构建,并接入多 Detector 内容分类和敏感检测。 +- Phase 4 已将 `ClipboardSnapshotBuilder` 升级为异步构建,并接入多 Detector 内容分类。 - Phase 1 不实现 Blob Store、图片缩略图或 GRDB 持久化;图片和大数据落盘属于 Phase 2。 - WriteGuard 只对已记录的内部写入忽略一次,避免用户手动再次复制相同内容时被长期过滤。 - 后台监控循环保存错误摘要而不是直接吞掉错误,也不记录剪贴板正文。 @@ -515,12 +487,10 @@ Phase 8:设置、导入导出、性能优化和发布准备。 - Quick Panel 的 AppKit 边界限定为 `NSPanel` 生命周期、焦点恢复和键盘事件路由;SwiftUI ViewModel 持有搜索、选择和命令状态。 - Phase 3 的 Return/Shift+Return 曾只执行剪贴板写回和面板关闭;Phase 6 已改为按用户设置执行 CGEvent 自动粘贴。 - Space 在当前 Quick Panel 按键路由中用于预览切换;包含空格的复杂查询可在后续文本焦点策略中继续细化。 -- Phase 4 将分类和敏感检测放在 `ClipboardSnapshotBuilder`,因为这是 pasteboard snapshot 转模型的唯一入口,能同时服务内存仓储和 GRDB 仓储。 +- Phase 4 将内容分类放在 `ClipboardSnapshotBuilder`,因为这是 pasteboard snapshot 转模型的唯一入口,能同时服务内存仓储和 GRDB 仓储。 - 分类器采用多个 `ContentDetector` 小组件,候选结果在 `DefaultContentClassifier` 统一去重排序。 -- 敏感检测 evidence 只保存标签,不保存匹配到的密钥、Token 或剪贴板正文。 -- potential 内容在当前阶段默认持久化但预览和搜索文本遮罩,并设置 10 分钟过期;后续设置页可提供“保留此记录”交互。 -- secret 内容默认不持久化、不进入 FTS,放入 `SensitiveEphemeralStore` 并 60 秒过期;忽略来源应用连短期内存缓存也不保留。 -- FTS 跳过逻辑由 entry metadata 驱动,便于后续导出、保留和策略 UI 复用同一安全决策。 +- 敏感内容遮罩功能已移除;本地个人版按原文保存、预览、索引和导出复制内容。 +- FTS 跳过逻辑仍支持 `metadata["shouldIndex"] == "false"`,用于未来手动排除索引场景。 - Phase 5 的转换动作全部是无状态 struct,通过 `TransformEngine` 注册和调度,便于测试和后续按类别展示。 - Phase 5 的转换结果只返回 `TransformResult`,不直接写剪贴板、不修改原始记录;用户确认写回仍留给后续 UI 流程。 - Base64 解码使用结构化校验:空输入返回空 Data,模 4 为 1 直接报错,模 4 为 2/3 自动补齐 Padding。 @@ -542,7 +512,7 @@ Phase 8:设置、导入导出、性能优化和发布准备。 - Phase 8 的设置页通过 `SettingsViewModel` 调用依赖容器中的协议服务,View 不直接访问 `SMAppService`、文件系统或仓储。 - 开机启动只通过 `LaunchAtLoginClient` 封装;单元测试使用内存实现,真实 `SMAppService` 不在自动测试中改写用户登录项。 - 加密导出使用 AES-GCM,口令派生使用 HKDF-SHA256 和随机 salt;未引入额外 KDF 依赖。 -- 导出策略默认保守:所有敏感记录都不导出,secret 即使未持久化也不会从导出通道泄露。 +- 导出策略默认完整:不按敏感标记跳过记录,只跳过 `shouldExport=false` 的手动排除记录。 - 导出包不直接包含 Blob 文件正文;当前 Phase 8 导出侧重文本、小型 inline representation 和文件引用元数据。 - Sparkle 2 只验证更新检查接口和状态,不引入 Sparkle 依赖;正式发布接入时再处理 Sparkle framework、签名和 appcast。 - 性能测试以 1 万条记录搜索基线覆盖回归;P95、空闲 CPU 和内存目标仍需要真实使用场景下的长期采样。 @@ -560,7 +530,7 @@ Phase 8:设置、导入导出、性能优化和发布准备。 - `xcode-select -p`:通过,当前路径为 `/Applications/Xcode.app/Contents/Developer`。 - `swift --version`:通过,Apple Swift version 6.3.2。 - `swift build`:通过。 -- `swift test`:通过,63 个测试全部通过。 +- `swift test`:通过,59 个测试全部通过。 - `xcodebuild -runFirstLaunch`:通过,首次启动组件安装成功。 - `xcodebuild -scheme DevClip -destination 'platform=macOS' build`:通过。 - `./script/build_and_run.sh --verify`:通过,已生成并启动 SwiftPM GUI app bundle;Info.plist 已写入 `CFBundleIconFile=AppIcon`,bundle 包含 `AppIcon.icns`。 @@ -612,7 +582,6 @@ Phase 8:设置、导入导出、性能优化和发布准备。 - `Sources/DevClipCore/Models/ClipboardEntry.swift` - `Sources/DevClipCore/Models/ClipboardOrganizationModels.swift` - `Sources/DevClipCore/Models/ClipboardRepresentation.swift` -- `Sources/DevClipCore/Models/SensitiveClassification.swift` - `Sources/DevClipCore/Models/TransformModels.swift` - `Sources/DevClipCore/Paste/PasteEngine.swift` - `Sources/DevClipCore/Paste/PasteAutomationClients.swift` @@ -629,8 +598,6 @@ Phase 8:设置、导入导出、性能优化和发布准备。 - `Sources/DevClipCore/Search/SearchQuery.swift` - `Sources/DevClipCore/Search/SearchQueryParser.swift` - `Sources/DevClipCore/Search/SearchService.swift` -- `Sources/DevClipCore/Security/SensitiveEphemeralStore.swift` -- `Sources/DevClipCore/Security/SensitiveContentDetector.swift` - `Sources/DevClipCore/Settings/LaunchAtLoginClient.swift` - `Sources/DevClipCore/Snippets/SnippetStore.swift` - `Sources/DevClipCore/Stack/ClipboardStackStore.swift`