diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7caea0c..e8bc4c12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,6 +96,7 @@ jobs: --public-key Flowlight/Resources/RulePacks/official-rule-packs-v1-public.pem \ --expected-key-id flowlight-rule-packs-2026-01 --current-app-version "$APP_VERSION" \ --extension-protocol 1 --now "$(date +%s)" + python3 scripts/release_notes.py "$APP_VERSION" >/dev/null - name: Check the site builds and is committed run: | diff --git a/Flowlight/Analysis/RulePackCatalogComposition.swift b/Flowlight/Analysis/RulePackCatalogComposition.swift new file mode 100644 index 00000000..82658d60 --- /dev/null +++ b/Flowlight/Analysis/RulePackCatalogComposition.swift @@ -0,0 +1,30 @@ +import Foundation + +enum RulePackCatalogComposition { + /// The signed feed may replace a built-in with the same stable ID or add an official pack. Omission is not a + /// tombstone: every built-in remains available as the signed-app emergency baseline. + static func compose(_ catalog: VerifiedRulePackCatalog, + currentAppVersion: String, + builtIns: [RulePackDefinition] = BuiltInRulePacks.all) throws -> VerifiedRulePackCatalog { + try RulePackValidator.validate(builtIns, currentAppVersion: currentAppVersion) + try RulePackValidator.validate(catalog.definitions, currentAppVersion: currentAppVersion) + + var merged: [String: RulePackDefinition] = [:] + for definition in builtIns { + guard merged.updateValue(definition, forKey: definition.id) == nil else { + throw RulePackValidationError.duplicatePack(definition.id) + } + } + for definition in catalog.definitions { + guard definition.id.hasPrefix("official.") else { + throw RulePackValidationError.invalidPackID(definition.id) + } + merged[definition.id] = definition + } + + var composed = catalog + composed.definitions = merged.values.sorted { $0.id < $1.id } + try RulePackValidator.validate(composed.definitions, currentAppVersion: currentAppVersion) + return composed + } +} diff --git a/Flowlight/Analysis/RulePackCatalogState.swift b/Flowlight/Analysis/RulePackCatalogState.swift new file mode 100644 index 00000000..b690d65f --- /dev/null +++ b/Flowlight/Analysis/RulePackCatalogState.swift @@ -0,0 +1,71 @@ +import Foundation + +struct RulePackCatalogReceipt: Equatable, Sendable { + var catalogID: String + var sequence: Int64 + var digest: String + var signerKeyID: String + var acceptedAt: Date +} + +struct RulePackCatalogCache: Equatable, Sendable { + var catalogID: String + /// Exact signed bytes. Re-encoding would invalidate both the signature evidence and byte-for-byte recovery. + var rawEnvelope: Data + var etag: String? + var lastModified: String? + var verifiedAt: Date +} + +struct RulePackCatalogUpdateState: Equatable, Sendable { + var catalogID: String + var automaticRefresh = true + var lastAttempt: Date? + var lastSuccess: Date? + var nextEligibleAttempt: Date? + var failureCount = 0 + var lastStatus: String? + var lastError: String? +} + +struct RulePackCatalogState: Equatable, Sendable { + var receipt: RulePackCatalogReceipt? + var cache: RulePackCatalogCache? + var update: RulePackCatalogUpdateState +} + +/// The exact content shown by an editor. Publisher authentication and user consent are separate decisions. +struct RulePackReviewReceipt: Equatable, Sendable { + var packID: String + var version: String + var contentSHA256: String + var catalogSequence: Int64 + var catalogDigest: String + + func matches(_ definition: RulePackDefinition, catalogSequence: Int64, catalogDigest: String) -> Bool { + packID == definition.id + && version == definition.manifest.version + && contentSHA256 == definition.manifest.contentSHA256 + && self.catalogSequence == catalogSequence + && self.catalogDigest == catalogDigest + } +} + +enum RulePackCatalogPersistenceError: Error, Equatable { + case invalidCatalogID + case invalidReceipt + case rollback + case equivocation + case staleReview +} + +struct RulePackCatalogAcceptance: Equatable, Sendable { + var state: RulePackCatalogState + /// All saved rows, including dormant IDs omitted by the currently active catalog. + var configurations: [RulePackConfiguration] + /// Durable user state is returned with every accepted catalog so an omitted and later reintroduced pack cannot + /// resume enforcement without the exceptions and history that still belong to its stable ID. + var overrides: [RulePackOverride] + var events: [RulePackEventRecord] + var deliveryStats: RulePackDeliveryStats +} diff --git a/Flowlight/Analysis/RulePackCatalogUpdater.swift b/Flowlight/Analysis/RulePackCatalogUpdater.swift new file mode 100644 index 00000000..28eaa1b8 --- /dev/null +++ b/Flowlight/Analysis/RulePackCatalogUpdater.swift @@ -0,0 +1,403 @@ +import Foundation + +@MainActor +final class RulePackCatalogUpdater: ObservableObject { + enum Status: Equatable, Sendable { + case idle + case checking + case current + case stale + case recovery(String) + case failed(String, usingLastKnownGood: Bool) + } + + enum UpdateError: Error, Equatable, CustomStringConvertible { + case invalidResponse + case wrongOrigin + case badStatus(Int) + case wrongMIME(String) + case responseTooLarge + case missingCache + case rejectedCatalogInstall + + var description: String { + switch self { + case .invalidResponse: return "The official catalog returned an invalid response." + case .wrongOrigin: return "The official catalog redirected outside its trusted HTTPS origin." + case .badStatus(let code): return "The official catalog returned HTTP \(code)." + case .wrongMIME(let value): return "The official catalog returned an unsupported content type: \(value)." + case .responseTooLarge: return "The official catalog exceeded the 8 MiB size limit." + case .missingCache: return "The server reported no change, but no verified cached catalog is available." + case .rejectedCatalogInstall: return "The committed official catalog could not be installed." + } + } + } + + nonisolated static let healthyInterval: TimeInterval = 24 * 60 * 60 + nonisolated static let startupDelay: TimeInterval = 8 + nonisolated static let failureBackoff: [TimeInterval] = [60 * 60, 6 * 60 * 60, 24 * 60 * 60] + + @Published private(set) var status: Status = .idle + @Published private(set) var catalogState: RulePackCatalogState + + var automaticRefresh: Bool { catalogState.update.automaticRefresh } + var receipt: RulePackCatalogReceipt? { catalogState.receipt } + + func isDownloadedUpdate(_ definition: RulePackDefinition) -> Bool { + guard let builtIn = BuiltInRulePacks.all.first(where: { $0.id == definition.id }) else { return true } + return builtIn != definition + } + + private let db: TrafficDatabase + private let store: RulePackStore + private let currentAppVersion: String + private let bundledCatalog: @Sendable () throws -> Data + private let trustedSigners: [RulePackCatalogTrustedSigner] + private let injectedTransport: HTTPDataTransport? + private let now: @Sendable () -> Date + private let sourceURL = URL(string: RulePackCatalogVerifier.sourceURL)! + private let productionTransport: BoundedHTTPDataTransport + private var timer: Timer? + private(set) var networkingEnabled = false + private var inFlight = false + private var requestGeneration = 0 + private var recoveryNeeded = false + + init(db: TrafficDatabase, + store: RulePackStore, + currentAppVersion: String, + bundledCatalog: @escaping @Sendable () throws -> Data, + transport: HTTPDataTransport? = nil, + trustedSigners: [RulePackCatalogTrustedSigner] = RulePackCatalogVerifier.productionTrustedSigners, + now: @escaping @Sendable () -> Date = { Date() }) { + self.db = db + self.store = store + self.currentAppVersion = currentAppVersion + self.bundledCatalog = bundledCatalog + self.injectedTransport = transport + self.trustedSigners = trustedSigners + self.now = now + productionTransport = BoundedHTTPDataTransport( + allowedOrigin: URL(string: RulePackCatalogVerifier.sourceURL)!, + maximumBytes: RulePackCatalogVerifier.maximumDataBytes + ) + catalogState = RulePackCatalogState( + receipt: nil, + cache: nil, + update: RulePackCatalogUpdateState(catalogID: RulePackCatalogVerifier.catalogID) + ) + } + + deinit { timer?.invalidate() } + + /// Establishes local trust before normal rule-pack state is loaded. It never performs network I/O. + func bootstrap() async { + do { + let bootstrap = try await db.asyncResult { + try $0.loadRulePackCatalogBootstrap(catalogID: RulePackCatalogVerifier.catalogID) + } + catalogState = bootstrap.state + + if let receipt = bootstrap.state.receipt { + guard let cache = bootstrap.state.cache else { + enterRecovery("The accepted official catalog cache is missing.") + return + } + do { + let verified = try verify( + cache.rawEnvelope, + receipt: receipt, + allowExpiredLastKnownGood: true + ) + let composed = try RulePackCatalogComposition.compose(verified, currentAppVersion: currentAppVersion) + store.installCommittedCatalog(composed, acceptance: bootstrap) + recoveryNeeded = false + status = verified.stale ? .stale : .current + } catch { + enterRecovery("The accepted official catalog cache could not be verified: \(error)") + } + return + } + + let verified = try verify(try bundledCatalog(), receipt: nil, allowExpiredLastKnownGood: false) + let composed = try RulePackCatalogComposition.compose(verified, currentAppVersion: currentAppVersion) + let checkedAt = now() + let acceptance = try await db.asyncResult { + try $0.acceptVerifiedRulePackCatalog( + composed, + checkedAt: checkedAt, + nextEligibleAt: checkedAt.addingTimeInterval(Self.healthyInterval) + ) + } + catalogState = acceptance.state + store.installCommittedCatalog(composed, acceptance: acceptance) + recoveryNeeded = false + status = verified.stale ? .stale : .current + } catch { + enterRecovery("The bundled official catalog could not be established: \(error)") + } + } + + func startScheduling(networkingEnabled: Bool) { + self.networkingEnabled = networkingEnabled + schedule() + } + + func setAutomaticRefresh(_ enabled: Bool) async { + do { + try await db.asyncResult { + try $0.setRulePackCatalogAutomaticRefresh( + catalogID: RulePackCatalogVerifier.catalogID, + enabled: enabled + ) + } + catalogState = try await db.asyncResult { + try $0.loadRulePackCatalogState(catalogID: RulePackCatalogVerifier.catalogID) + } + schedule() + } catch { + status = .failed(String(describing: error), usingLastKnownGood: store.snapshot != nil) + } + } + + /// Manual checks bypass the persisted due time, but never any transport, signature, compatibility, or rollback gate. + func checkNow() async { + await check(manual: true) + } + + func checkIfDue() async { + await check(manual: false) + } + + private func check(manual: Bool) async { + guard networkingEnabled, !inFlight else { return } + let attempt = now() + if !manual { + guard catalogState.update.automaticRefresh else { return } + if let next = catalogState.update.nextEligibleAttempt, next > attempt { schedule(); return } + } + + inFlight = true + requestGeneration += 1 + let generation = requestGeneration + status = .checking + defer { + if generation == requestGeneration { inFlight = false; schedule() } + } + + do { + let request = makeRequest(useValidators: !recoveryNeeded) + let (data, response) = try await transport(request) + try Task.checkCancellation() + guard generation == requestGeneration else { return } + guard let http = response as? HTTPURLResponse, let finalURL = http.url else { + throw UpdateError.invalidResponse + } + guard sameOrigin(finalURL) else { throw UpdateError.wrongOrigin } + + switch http.statusCode { + case 200: + try validateJSONResponse(http, data: data) + let prior = catalogState.receipt + let verified = try verify(data, receipt: prior, allowExpiredLastKnownGood: false) + let composed = try RulePackCatalogComposition.compose(verified, currentAppVersion: currentAppVersion) + let storeRevision = store.catalogInstallRevision + var acceptance = try await db.asyncResult { + try $0.acceptVerifiedRulePackCatalog( + composed, + etag: http.value(forHTTPHeaderField: "ETag"), + lastModified: http.value(forHTTPHeaderField: "Last-Modified"), + checkedAt: attempt, + nextEligibleAt: attempt.addingTimeInterval(Self.healthyInterval) + ) + } + guard generation == requestGeneration, + let installed = try await installCommittedCatalog( + composed, + acceptance: acceptance, + expectedRevision: storeRevision, + generation: generation + ) else { return } + acceptance = installed + catalogState = acceptance.state + recoveryNeeded = false + status = .current + + case 304: + guard !recoveryNeeded, + let receipt = catalogState.receipt, + let cache = catalogState.cache else { throw UpdateError.missingCache } + let verified = try verify( + cache.rawEnvelope, + receipt: receipt, + allowExpiredLastKnownGood: true + ) + let composed = try RulePackCatalogComposition.compose(verified, currentAppVersion: currentAppVersion) + let storeRevision = store.catalogInstallRevision + try await db.asyncResult { + try $0.recordRulePackCatalogNotModified( + catalogID: RulePackCatalogVerifier.catalogID, + expectedSequence: receipt.sequence, + expectedDigest: receipt.digest, + checkedAt: attempt, + nextEligibleAt: attempt.addingTimeInterval(Self.healthyInterval) + ) + } + guard generation == requestGeneration else { return } + let acceptance = try await db.asyncResult { + try $0.loadRulePackCatalogBootstrap(catalogID: RulePackCatalogVerifier.catalogID) + } + guard generation == requestGeneration, + let installed = try await installCommittedCatalog( + composed, + acceptance: acceptance, + expectedRevision: storeRevision, + generation: generation + ) else { return } + catalogState = installed.state + status = verified.stale ? .stale : .current + + default: + throw UpdateError.badStatus(http.statusCode) + } + } catch is CancellationError { + return + } catch { + guard generation == requestGeneration else { return } + await recordFailure(error, attemptedAt: attempt) + } + } + + private func installCommittedCatalog( + _ catalog: VerifiedRulePackCatalog, + acceptance initialAcceptance: RulePackCatalogAcceptance, + expectedRevision initialRevision: Int, + generation: Int + ) async throws -> RulePackCatalogAcceptance? { + var acceptance = initialAcceptance + var expectedRevision = initialRevision + while generation == requestGeneration { + switch store.installCommittedCatalog( + catalog, + acceptance: acceptance, + expectedRevision: expectedRevision + ) { + case .installed: + return acceptance + case .rejected: + throw UpdateError.rejectedCatalogInstall + case .staleRevision: + expectedRevision = store.catalogInstallRevision + acceptance = try await db.asyncResult { + try $0.loadRulePackCatalogBootstrap(catalogID: RulePackCatalogVerifier.catalogID) + } + } + } + return nil + } + + private func makeRequest(useValidators: Bool) -> URLRequest { + var request = URLRequest(url: sourceURL) + request.httpMethod = "GET" + request.timeoutInterval = 20 + request.setValue("Flowlight/\(currentAppVersion)", forHTTPHeaderField: "User-Agent") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if useValidators { + if let etag = catalogState.cache?.etag { + request.setValue(etag, forHTTPHeaderField: "If-None-Match") + } + if let modified = catalogState.cache?.lastModified { + request.setValue(modified, forHTTPHeaderField: "If-Modified-Since") + } + } + return request + } + + private func validateJSONResponse(_ response: HTTPURLResponse, data: Data) throws { + guard data.count <= RulePackCatalogVerifier.maximumDataBytes else { + throw UpdateError.responseTooLarge + } + if let length = response.value(forHTTPHeaderField: "Content-Length"), + let count = Int64(length), count > Int64(RulePackCatalogVerifier.maximumDataBytes) { + throw UpdateError.responseTooLarge + } + let mime = response.mimeType?.lowercased() ?? "" + guard mime == "application/json" else { throw UpdateError.wrongMIME(mime) } + } + + private func verify(_ data: Data, receipt: RulePackCatalogReceipt?, + allowExpiredLastKnownGood: Bool) throws -> VerifiedRulePackCatalog { + try RulePackCatalogVerifier.verify( + data: data, + trustedSigners: trustedSigners, + now: Int64(now().timeIntervalSince1970), + currentAppVersion: currentAppVersion, + priorSequence: receipt?.sequence, + priorDigest: receipt?.digest, + allowExpiredLastKnownGood: allowExpiredLastKnownGood + ) + } + + private func transport(_ request: URLRequest) async throws -> (Data, URLResponse) { + if let injectedTransport { return try await injectedTransport(request) } + return try await productionTransport.data(for: request) + } + + private func recordFailure(_ error: Error, attemptedAt: Date) async { + let failures = min(catalogState.update.failureCount + 1, Int.max) + let delay = Self.failureBackoff[min(failures - 1, Self.failureBackoff.count - 1)] + let failureStatus = recoveryNeeded ? "recovery-failed" : "failed" + let failureDescription = String(describing: error) + do { + try await db.asyncResult { + try $0.recordRulePackCatalogFailure( + catalogID: RulePackCatalogVerifier.catalogID, + attemptedAt: attemptedAt, + failureCount: failures, + nextEligibleAt: attemptedAt.addingTimeInterval(delay), + status: failureStatus, + error: failureDescription + ) + } + catalogState = try await db.asyncResult { + try $0.loadRulePackCatalogState(catalogID: RulePackCatalogVerifier.catalogID) + } + } catch { + // The catalog and active snapshot remain unchanged if even failure bookkeeping cannot commit. + } + status = recoveryNeeded + ? .recovery(String(describing: error)) + : .failed(String(describing: error), usingLastKnownGood: catalogState.receipt != nil) + } + + private func enterRecovery(_ message: String) { + recoveryNeeded = true + store.enterCatalogRecovery() + status = .recovery(message) + } + + private func schedule() { + timer?.invalidate() + timer = nil + guard networkingEnabled, catalogState.update.automaticRefresh, !inFlight else { return } + let delay: TimeInterval + if let next = catalogState.update.nextEligibleAttempt { + delay = max(0, next.timeIntervalSince(now())) + } else { + delay = Self.startupDelay + } + timer = Timer.scheduledTimer(withTimeInterval: max(0.1, delay), repeats: false) { [weak self] _ in + Task { @MainActor in await self?.checkIfDue() } + } + } + + private func sameOrigin(_ candidate: URL) -> Bool { + candidate.scheme?.lowercased() == sourceURL.scheme?.lowercased() + && candidate.host?.lowercased() == sourceURL.host?.lowercased() + && effectivePort(candidate) == effectivePort(sourceURL) + } + + private func effectivePort(_ url: URL) -> Int? { + url.port ?? (url.scheme?.lowercased() == "https" ? 443 : url.scheme?.lowercased() == "http" ? 80 : nil) + } +} diff --git a/Flowlight/Analysis/RulePackEventDelivery.swift b/Flowlight/Analysis/RulePackEventDelivery.swift new file mode 100644 index 00000000..2ea2350e --- /dev/null +++ b/Flowlight/Analysis/RulePackEventDelivery.swift @@ -0,0 +1,135 @@ +import Foundation + +/// Receives app-originated managed-pack events with the same durable watermark contract as extension events. +/// Every refusal has its own deadline: a stalled write cannot make unrelated connections wait behind it. A retry +/// reuses its session and sequence so an uncertain acknowledgement cannot count a refusal twice. +final class RulePackEventDelivery: @unchecked Sendable { + typealias Claim = @Sendable () -> Bool + typealias Sink = @Sendable ( + RulePackEventBatch, + @escaping Claim, + @escaping @Sendable (Bool) -> Void + ) -> Void + + private final class AttemptLease: @unchecked Sendable { + private let lock = NSLock() + private var active = true + private var claimed = false + + func claim() -> Bool { + lock.lock(); defer { lock.unlock() } + guard active else { return false } + claimed = true + return true + } + + /// A claimed attempt has begun its serialized durable write and must be allowed to finish. An unclaimed one + /// can be cancelled without any later activity appearing for a connection that was allowed on timeout. + func cancelIfUnclaimed() -> Bool { + lock.lock(); defer { lock.unlock() } + guard active, !claimed else { return false } + active = false + return true + } + + func invalidate() { + lock.lock(); defer { lock.unlock() } + active = false + } + } + + private struct Pending: Sendable { + var batch: RulePackEventBatch + var attempts = 0 + var generation: UInt64 + var attemptID: UInt64 = 0 + var lease: AttemptLease? + var completion: @Sendable (Bool) -> Void + } + + private let queue = DispatchQueue(label: "flowlight.rule-pack.proxy-events") + private let retryDelay: TimeInterval + private let attemptTimeout: TimeInterval + private var generation: UInt64 = 0 + private var nextDeliveryID: Int64 = 0 + private var pending: [Int64: Pending] = [:] + private var attemptID: UInt64 = 0 + private let sink: Sink + + init(retryDelay: TimeInterval = 0.1, + attemptTimeout: TimeInterval = 5, + sink: @escaping Sink) { + self.retryDelay = retryDelay + self.attemptTimeout = attemptTimeout + self.sink = sink + } + + func enqueue(_ event: RulePackEvent, completion: @escaping @Sendable (Bool) -> Void) { + queue.async { [self] in + nextDeliveryID += 1 + let deliveryID = nextDeliveryID + // App-local retries complete against the same serialized database callback and need no permanent + // watermark row. The immutable batch ID still makes every retry of this refusal identical. + let batch = RulePackEventBatch( + deliverySession: UUID(), + sequence: 1, + requiresDurableWatermark: false, + events: [event] + ) + pending[deliveryID] = Pending(batch: batch, generation: generation, completion: completion) + deliver(deliveryID: deliveryID) + } + } + + /// Fails pre-clear authorizations open and prevents every unclaimed database block from beginning. A write that + /// already claimed its place on the serial database queue finishes before the clear scheduled by the completion. + func invalidate(completion: @escaping @Sendable () -> Void) { + queue.async { [self] in + generation &+= 1 + let abandoned = Array(pending.values) + pending.removeAll() + abandoned.forEach { + $0.lease?.invalidate() + $0.completion(false) + } + completion() + } + } + + private func deliver(deliveryID: Int64) { + guard var item = pending[deliveryID], item.generation == generation else { return } + attemptID &+= 1 + let thisAttempt = attemptID + let lease = AttemptLease() + item.attemptID = thisAttempt + item.lease = lease + pending[deliveryID] = item + sink(item.batch, lease.claim) { [weak self] committed in + self?.queue.async { + self?.finished(committed: committed, deliveryID: deliveryID, attemptID: thisAttempt) + } + } + queue.asyncAfter(deadline: .now() + attemptTimeout) { [weak self] in + guard lease.cancelIfUnclaimed() else { return } + self?.finished(committed: false, deliveryID: deliveryID, attemptID: thisAttempt) + } + } + + private func finished(committed: Bool, deliveryID: Int64, attemptID finishedAttempt: UInt64) { + guard var item = pending[deliveryID], item.attemptID == finishedAttempt, + item.generation == generation else { return } + item.lease?.invalidate() + if committed { + pending.removeValue(forKey: deliveryID)?.completion(true) + return + } + if item.attempts == 0 { + item.attempts = 1 + item.lease = nil + pending[deliveryID] = item + queue.asyncAfter(deadline: .now() + retryDelay) { [weak self] in self?.deliver(deliveryID: deliveryID) } + } else { + pending.removeValue(forKey: deliveryID)?.completion(false) + } + } +} diff --git a/Flowlight/Analysis/RulePackStore.swift b/Flowlight/Analysis/RulePackStore.swift index 0162cfd8..944986e5 100644 --- a/Flowlight/Analysis/RulePackStore.swift +++ b/Flowlight/Analysis/RulePackStore.swift @@ -9,7 +9,37 @@ extension RulePackOverride { } } -/// The app-owned half of managed rule packs: built-in definitions, user activation/scope, exceptions, and the +/// The immutable pack generation the inspection proxy may carry out. A committed extension acknowledgement swaps the +/// whole matcher in; applying, fallback, and failure clear it so the UI cannot say Blocking while the proxy uses stale policy. +final class LiveRulePacks: @unchecked Sendable { + private let lock = NSLock() + private var digest = "" + private var matcher: RulePackMatcher? + + func replace(snapshot: RulePackSnapshot?) { + let next = snapshot.flatMap { try? RulePackMatcher(snapshot: $0) } + lock.lock(); defer { lock.unlock() } + digest = next == nil ? "" : (snapshot?.digest ?? "") + matcher = next + } + + func commit(snapshot: RulePackSnapshot?, activeDigest: String) { + guard let snapshot, snapshot.digest == activeDigest else { replace(snapshot: nil); return } + replace(snapshot: snapshot) + } + + func match(host: String, bundleID: String) -> RulePackMatch? { + lock.lock(); defer { lock.unlock() } + return matcher?.matches(host: host, bundleID: bundleID).first + } + + var activeDigest: String { + lock.lock(); defer { lock.unlock() } + return digest + } +} + +/// The app-owned half of managed rule packs: verified definitions, user activation/scope, exceptions, and the /// complete validated snapshot handed to the Network Extension. @MainActor final class RulePackStore: ObservableObject { @@ -17,34 +47,60 @@ final class RulePackStore: ObservableObject { case off, ready, applying, enforcing, watchingOnly, incompatible(String), failed(String) } + enum SaveResult: Equatable, Sendable { + case saved + case staleReview + case failed(String) + + var succeeded: Bool { self == .saved } + } + + enum CatalogInstallResult: Equatable, Sendable { + case installed + case staleRevision + case rejected + } + @Published private(set) var configurations: [RulePackConfiguration] = [] @Published private(set) var overrides: [RulePackOverride] = [] @Published private(set) var events: [RulePackEventRecord] = [] @Published private(set) var deliveryStats = RulePackDeliveryStats(droppedHits: 0, droppedKeys: 0) @Published private(set) var status: ApplyStatus = .off @Published private(set) var version = 0 + @Published private(set) var definitions: [RulePackDefinition] - let definitions: [RulePackDefinition] var onChange: (RulePackSnapshot?) -> Void = { _ in } private weak var db: TrafficDatabase? private var definitionsValid = true + private var catalogReceipt: RulePackCatalogReceipt? + private var committedSnapshot: RulePackSnapshot? + private var revision = 0 + private var activityRevision = 0 + private var clearingActivity = false + private let currentAppVersion: String - init(definitions: [RulePackDefinition] = BuiltInRulePacks.all) { - self.definitions = definitions - do { try RulePackValidator.validate(definitions) } + init(definitions: [RulePackDefinition] = BuiltInRulePacks.all, + currentAppVersion: String = ExtensionManager.appVersion) { + self.definitions = definitions.sorted { $0.id < $1.id } + self.currentAppVersion = currentAppVersion + do { try RulePackValidator.validate(self.definitions, currentAppVersion: currentAppVersion) } catch { definitionsValid = false status = .failed("Built-in pack validation failed: \(error)") } + rebuildSnapshot() } - var enabledCount: Int { configurations.filter(\.enabled).count } - var hasEnabledPacks: Bool { enabledCount > 0 } + var enabledCount: Int { committedSnapshot?.configurations.count ?? 0 } + var hasEnabledPacks: Bool { committedSnapshot != nil } var failureMessage: String? { if case .failed(let message) = status { return message } return nil } + var snapshot: RulePackSnapshot? { committedSnapshot } + var catalogInstallRevision: Int { revision } + func configuration(for packID: String) -> RulePackConfiguration { configurations.first { $0.packID == packID } ?? RulePackConfiguration(packID: packID) } @@ -53,63 +109,179 @@ final class RulePackStore: ObservableObject { overrides.filter { $0.packID == packID } } - func attach(db: TrafficDatabase) { + func needsReview(_ definition: RulePackDefinition) -> Bool { + guard let configuration = configurations.first(where: { $0.packID == definition.id }) else { return false } + return configuration.consentedVersion != nil && !configuration.isConsented(to: definition) + } + + func reviewReceipt(for definition: RulePackDefinition) -> RulePackReviewReceipt? { + guard let catalogReceipt else { return nil } + return RulePackReviewReceipt( + packID: definition.id, + version: definition.manifest.version, + contentSHA256: definition.manifest.contentSHA256, + catalogSequence: catalogReceipt.sequence, + catalogDigest: catalogReceipt.digest + ) + } + + func attach(db: TrafficDatabase, completion: (() -> Void)? = nil) { self.db = db - reload() + reload(completion: completion) } - func reload() { + func reload(completion: (() -> Void)? = nil) { guard let db else { return } - db.async { [weak self] db in - let saved = try db.loadRulePackConfigurations() - let exceptions = try db.loadRulePackOverrides() - let events = try db.rulePackEvents(limit: 300) - let deliveryStats = try db.rulePackDeliveryStats() - Task { @MainActor in + revision += 1 + let expectedRevision = revision + Task { [weak self, weak db] in + guard let db else { return } + do { + let loaded = try await db.asyncResult { db in + (try db.loadRulePackConfigurations(), try db.loadRulePackOverrides(), + try db.rulePackEvents(limit: 300), try db.rulePackDeliveryStats(), + try db.loadRulePackCatalogState(catalogID: RulePackCatalogVerifier.catalogID).receipt) + } + guard let self else { return } + if self.revision == expectedRevision { + self.catalogReceipt = loaded.4 + self.applyLoaded(configurations: loaded.0, overrides: loaded.1, + events: loaded.2, deliveryStats: loaded.3) + } + // A concurrent clear or save supersedes these loaded values, not startup readiness. The caller still + // needs its one completion so capture can start from the newer in-memory publication. + completion?() + } catch { guard let self else { return } - let known = Set(self.definitions.map(\.id)) - self.configurations = self.definitions.map { definition in - guard var configuration = saved.first(where: { $0.packID == definition.id }) else { - return RulePackConfiguration(packID: definition.id) - } - if configuration.enabled, !configuration.isConsented(to: definition) { - configuration.enabled = false - } - return configuration + if self.revision == expectedRevision { + self.status = .failed("Could not load rule packs: \(String(describing: error))") } - self.overrides = exceptions.filter { known.contains($0.packID) } - self.events = events.filter { known.contains($0.packID) } - self.deliveryStats = deliveryStats - self.publish() + completion?() } } } + /// Prevents a higher durable watermark with missing/corrupt bytes from falling back to bundled enforcement while + /// a recovery copy is fetched. Saved scope and consent remain in SQLite; no definition can enter a snapshot. + func enterCatalogRecovery() { + revision += 1 + definitions = [] + definitionsValid = true + catalogReceipt = nil + configurations = [] + overrides = [] + committedSnapshot = nil + publish() + } + + /// Installs definitions only after `acceptVerifiedRulePackCatalog` committed them. The optional revision is a + /// compare-and-swap guard: a local save that publishes while catalog I/O is suspended makes captured state stale. + @discardableResult + func installCommittedCatalog(_ catalog: VerifiedRulePackCatalog, + acceptance: RulePackCatalogAcceptance, + expectedRevision: Int? = nil) -> CatalogInstallResult { + guard expectedRevision == nil || expectedRevision == revision else { return .staleRevision } + do { try RulePackValidator.validate(catalog.definitions, currentAppVersion: currentAppVersion) } + catch { + status = .failed("Verified pack validation failed: \(error)") + return .rejected + } + guard acceptance.state.receipt?.sequence == catalog.sequence, + acceptance.state.receipt?.digest == catalog.digest else { + status = .failed("Verified catalog receipt does not match committed storage.") + return .rejected + } + revision += 1 + definitions = catalog.definitions.sorted { $0.id < $1.id } + definitionsValid = true + catalogReceipt = acceptance.state.receipt + configurations = effectiveConfigurations(from: acceptance.configurations) + let known = Set(definitions.map(\.id)) + overrides = acceptance.overrides.filter { known.contains($0.packID) } + events = acceptance.events.filter { known.contains($0.packID) } + deliveryStats = acceptance.deliveryStats + publish() + return .installed + } + + /// Receipt-free writes can only narrow enforcement. Every enabling path must use the immutable-review overload. func save(_ configuration: RulePackConfiguration, completion: ((Bool) -> Void)? = nil) { - guard definitions.contains(where: { $0.id == configuration.packID }) else { + guard definitions.contains(where: { $0.id == configuration.packID }), !configuration.enabled else { completion?(false) return } var cleaned = configuration - if cleaned.enabled, let definition = definitions.first(where: { $0.id == cleaned.packID }) { - cleaned.consentedVersion = definition.manifest.version - cleaned.consentedContentSHA256 = definition.manifest.contentSHA256 - } cleaned.apps = Array(Set(configuration.apps.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty })).sorted() if cleaned.scope == .selectedApps && cleaned.apps.isEmpty { cleaned.enabled = false } - persist({ try $0.saveRulePackConfiguration(cleaned) }, then: { [weak self] in + let committed = cleaned + persist({ try $0.saveRulePackConfiguration(committed) }, then: { [weak self] in guard let self else { completion?(false); return } - if let index = self.configurations.firstIndex(where: { $0.packID == cleaned.packID }) { - self.configurations[index] = cleaned + self.revision += 1 + if let index = self.configurations.firstIndex(where: { $0.packID == committed.packID }) { + self.configurations[index] = committed } else { - self.configurations.append(cleaned) + self.configurations.append(committed) + self.configurations.sort { $0.packID < $1.packID } } self.publish() completion?(true) - }, onFailure: { - completion?(false) + }, onFailure: { _ in completion?(false) }) + } + + func save(_ configuration: RulePackConfiguration, + reviewedDefinition: RulePackDefinition?, + receipt: RulePackReviewReceipt?, + completion: ((SaveResult) -> Void)? = nil) { + guard let current = definitions.first(where: { $0.id == configuration.packID }) else { + completion?(.failed("Rule pack is no longer available.")) + return + } + var cleaned = configuration + cleaned.apps = Array(Set(configuration.apps.map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + }.filter { !$0.isEmpty })).sorted() + if cleaned.scope == .selectedApps && cleaned.apps.isEmpty { cleaned.enabled = false } + + var durableReceipt: RulePackReviewReceipt? + if cleaned.enabled { + guard let reviewedDefinition, reviewedDefinition == current, + let receipt, receipt.packID == current.id, + receipt.version == current.manifest.version, + receipt.contentSHA256 == current.manifest.contentSHA256, + catalogReceipt?.sequence == receipt.catalogSequence, + catalogReceipt?.digest == receipt.catalogDigest else { + completion?(.staleReview) + return + } + cleaned.consentedVersion = reviewedDefinition.manifest.version + cleaned.consentedContentSHA256 = reviewedDefinition.manifest.contentSHA256 + durableReceipt = receipt + } + + let committed = cleaned + let committedReceipt = durableReceipt + persist({ try $0.saveRulePackConfiguration(committed, reviewed: committedReceipt) }, then: { [weak self] in + guard let self else { + completion?(.failed("Rule pack storage is unavailable.")) + return + } + self.revision += 1 + if let index = self.configurations.firstIndex(where: { $0.packID == committed.packID }) { + self.configurations[index] = committed + } else { + self.configurations.append(committed) + self.configurations.sort { $0.packID < $1.packID } + } + self.publish() + completion?(.saved) + }, onFailure: { error in + if error as? RulePackCatalogPersistenceError == .staleReview { + completion?(.staleReview) + } else { + completion?(.failed(String(describing: error))) + } }) } @@ -128,25 +300,24 @@ final class RulePackStore: ObservableObject { var cleaned = override cleaned.host = host cleaned.app = override.app.trimmingCharacters(in: .whitespacesAndNewlines) - persist({ try $0.saveRulePackOverride(cleaned) }, then: { [weak self] in + let committed = cleaned + persist({ try $0.saveRulePackOverride(committed) }, then: { [weak self] in guard let self else { completion?(false); return } - self.overrides.removeAll { $0.id == cleaned.id || $0.hasSameScope(as: cleaned) } - self.overrides.append(cleaned) + self.revision += 1 + self.overrides.removeAll { $0.id == committed.id || $0.hasSameScope(as: committed) } + self.overrides.append(committed) self.publish() completion?(true) - }, onFailure: { - completion?(false) - }) + }, onFailure: { _ in completion?(false) }) } func deleteOverride(_ override: RulePackOverride, completion: ((Bool) -> Void)? = nil) { persist({ try $0.deleteRulePackOverride(override) }, then: { [weak self] in + self?.revision += 1 self?.overrides.removeAll { $0.hasSameScope(as: override) } self?.publish() completion?(true) - }, onFailure: { - completion?(false) - }) + }, onFailure: { _ in completion?(false) }) } func updateStatus(_ newStatus: ApplyStatus) { status = newStatus } @@ -156,80 +327,142 @@ final class RulePackStore: ObservableObject { status = .applying } - func record(_ batch: RulePackEventBatch, completion: @escaping (Bool) -> Void) { + func record(_ batch: RulePackEventBatch, + claim: @escaping @Sendable () -> Bool = { true }, + acknowledgeDuringClear: Bool = true, + completion: @escaping @Sendable (Bool) -> Void) { + guard !clearingActivity else { completion(acknowledgeDuringClear); return } guard let db else { completion(false); return } + let expectedActivityRevision = activityRevision db.queue.async { [weak self, weak db] in guard let db else { completion(false); return } + guard claim() else { completion(false); return } do { _ = try db.recordRulePackEventBatch(batch) + } catch { + appLog.error("rule pack batch failed: \(String(describing: error), privacy: .public)") + completion(false) + return + } + // Authorization waits only for the durable transaction. Feed loading and MainActor publication are + // not part of the refusal critical path and cannot hold a connection after SQLite has committed it. + completion(true) + do { let events = try db.rulePackEvents(limit: 300) let deliveryStats = try db.rulePackDeliveryStats() Task { @MainActor in - self?.events = events - self?.deliveryStats = deliveryStats - self?.version += 1 - completion(true) + guard let self, + self.activityRevision == expectedActivityRevision else { return } + self.revision += 1 + self.events = events + self.deliveryStats = deliveryStats + self.version += 1 } } catch { - appLog.error("rule pack batch failed: \(String(describing: error), privacy: .public)") - Task { @MainActor in completion(false) } + appLog.error("rule pack activity refresh failed: \(String(describing: error), privacy: .public)") } } } func events(for packID: String) -> [RulePackEventRecord] { events.filter { $0.packID == packID } } - func clearActivity() { + func beginActivityClear() { + clearingActivity = true + revision += 1 + activityRevision += 1 events = [] deliveryStats = RulePackDeliveryStats(droppedHits: 0, droppedKeys: 0) version += 1 } - var snapshot: RulePackSnapshot? { - let definitionsByID = Dictionary(uniqueKeysWithValues: definitions.map { ($0.id, $0) }) - let active = configurations.filter { configuration in - configuration.enabled - && definitionsByID[configuration.packID].map(configuration.isConsented(to:)) == true + func finishActivityClear(succeeded: Bool) { + clearingActivity = false + if !succeeded { reload() } + } + + /// Clears only the published model after the database rows have already been deleted by the caller. + func clearPublishedActivityAfterDurableDeletion() { + beginActivityClear() + finishActivityClear(succeeded: true) + } + + private func applyLoaded(configurations saved: [RulePackConfiguration], + overrides loadedOverrides: [RulePackOverride], + events loadedEvents: [RulePackEventRecord], + deliveryStats loadedStats: RulePackDeliveryStats) { + let known = Set(definitions.map(\.id)) + configurations = effectiveConfigurations(from: saved) + overrides = loadedOverrides.filter { known.contains($0.packID) } + events = loadedEvents.filter { known.contains($0.packID) } + deliveryStats = loadedStats + publish() + } + + private func effectiveConfigurations(from saved: [RulePackConfiguration]) -> [RulePackConfiguration] { + definitions.map { definition in + guard var configuration = saved.first(where: { $0.packID == definition.id }) else { + return RulePackConfiguration(packID: definition.id) + } + if configuration.enabled, !configuration.isConsented(to: definition) { + configuration.enabled = false + } + return configuration } - guard !active.isEmpty else { return nil } - let packIDs = Set(active.map(\.packID)) - let included = definitions.filter { packIDs.contains($0.id) } - let includedOverrides = overrides.filter { packIDs.contains($0.packID) } - return RulePackSnapshot.make(definitions: included, configurations: active, overrides: includedOverrides) } - private func persist(_ write: @escaping (TrafficDatabase) throws -> Void, + private func persist(_ write: @escaping @Sendable (TrafficDatabase) throws -> Void, then commit: @escaping @MainActor () -> Void, - onFailure: (@MainActor () -> Void)? = nil) { + onFailure: (@MainActor (Error) -> Void)? = nil) { guard let db else { + let error = RulePackCatalogPersistenceError.invalidReceipt status = .failed("Rule pack storage is unavailable.") - onFailure?() + onFailure?(error) return } - db.queue.async { [weak self, weak db] in + Task { [weak self, weak db] in guard let db else { - Task { @MainActor in - self?.status = .failed("Rule pack storage is unavailable.") - onFailure?() - } + let error = RulePackCatalogPersistenceError.invalidReceipt + self?.status = .failed("Rule pack storage is unavailable.") + onFailure?(error) return } do { - try write(db) - Task { @MainActor in commit() } + try await db.asyncResult { db in try write(db) } + commit() } catch { - Task { @MainActor in - self?.status = .failed("Could not save the rule pack change: \(String(describing: error))") - onFailure?() - } + self?.status = .failed("Could not save the rule pack change: \(String(describing: error))") + onFailure?(error) } } } + private func rebuildSnapshot() { + guard definitionsValid else { + committedSnapshot = nil + return + } + let definitionsByID = Dictionary(uniqueKeysWithValues: definitions.map { ($0.id, $0) }) + let active = configurations.filter { configuration in + configuration.enabled + && definitionsByID[configuration.packID].map(configuration.isConsented(to:)) == true + } + guard !active.isEmpty else { + committedSnapshot = nil + return + } + let packIDs = Set(active.map(\.packID)) + committedSnapshot = RulePackSnapshot.make( + definitions: definitions.filter { packIDs.contains($0.id) }, + configurations: active, + overrides: overrides.filter { packIDs.contains($0.packID) } + ) + } + private func publish() { + rebuildSnapshot() version += 1 guard definitionsValid else { return } status = hasEnabledPacks ? .ready : .off - onChange(snapshot) + onChange(committedSnapshot) } } diff --git a/Flowlight/Analysis/RuleStore.swift b/Flowlight/Analysis/RuleStore.swift index d6639819..4bed65b1 100644 --- a/Flowlight/Analysis/RuleStore.swift +++ b/Flowlight/Analysis/RuleStore.swift @@ -22,6 +22,12 @@ final class RuleStore: ObservableObject { var onChange: (RuleSet) -> Void = { _ in } private weak var db: TrafficDatabase? + private var pendingHitResets: Set = [] + private var pendingRuleInvalidations: Set = [] + /// The first whole-list generation that describes each rule's current definition. Delayed decisions from an + /// older definition are ignored, while an unrelated rule edit does not erase a valid spend. + private var ruleInvalidatedAt: [UUID: UInt64] = [:] + private var publicationGeneration: UInt64 = 0 private var pauseTimer: Timer? /// The same rules, readable from the proxy's queue. The store itself is main-actor — it is a published model — /// but the proxy asks which rules apply while framing a request, so it needs an answer without a hop. @@ -69,12 +75,14 @@ final class RuleStore: ObservableObject { } else { rules.append(rule) } + pendingRuleInvalidations.insert(rule.id) db?.async { try $0.saveRule(rule) } publish() } func delete(_ rule: Rule) { rules.removeAll { $0.id == rule.id } + pendingRuleInvalidations.insert(rule.id) db?.async { try $0.deleteRule(rule.id) } publish() } @@ -90,6 +98,7 @@ final class RuleStore: ObservableObject { guard var found = rules.first(where: { $0.id == rule.id }) else { return } found.hits = 0 found.lastHit = nil + pendingHitResets.insert(found.id) save(found) } @@ -119,22 +128,126 @@ final class RuleStore: ObservableObject { /// Decisions from the filter: recorded, counted, and shown. Both refusals and relaxations arrive here. func record(_ incoming: [RuleEvent]) { guard !incoming.isEmpty else { return } + var accepted: [RuleEvent] = [] for event in incoming { + if let eventSession = event.ruleSession, + let eventGeneration = event.ruleGeneration, + !acceptsDecision( + ruleID: event.ruleID, + session: eventSession, + generation: eventGeneration + ) { + continue + } + accepted.append(event) guard let index = rules.firstIndex(where: { $0.id == event.ruleID }) else { continue } - rules[index].hits += 1 + rules[index].hits = rules[index].maxHits > 0 + ? min(rules[index].maxHits, rules[index].hits + 1) + : rules[index].hits + 1 rules[index].lastHit = Date(timeIntervalSince1970: TimeInterval(event.at)) let rule = rules[index] db?.async { try $0.saveRule(rule) } } + guard !accepted.isEmpty else { return } db?.async { [weak self] db in - try db.recordRuleEvents(incoming) + try db.recordRuleEvents(accepted) let events = (try? db.ruleEvents(limit: 300)) ?? [] Task { @MainActor in self?.events = events self?.version += 1 } } - publish() + publish(advancingGeneration: false) + } + + /// Clears the published feed after its durable rows were deleted by the caller. + func clearPublishedEventsAfterDurableDeletion() { + events = [] + version += 1 + } + + /// Persists an accepted bounded proxy claim and its visible explanation before the proxy acts on the decision. + /// The extension already spent the claim; a failed write therefore fails this connection open and leaves the local + /// mirror spent so neither side can quietly reuse it during this process. + func recordBoundedClaim( + _ claim: RuleClaim, + event: RuleEvent, + completion: @escaping @Sendable (Bool) -> Void + ) { + guard event.ruleID == claim.ruleID, + event.ruleSession == claim.session, + event.ruleGeneration == claim.generation, + acceptsDecision(ruleID: claim.ruleID, session: claim.session, generation: claim.generation), + let index = rules.firstIndex(where: { $0.id == claim.ruleID }), + rules[index].maxHits > 0 else { + completion(false) + return + } + rules[index].hits = min(rules[index].maxHits, rules[index].hits + 1) + rules[index].lastHit = Date(timeIntervalSince1970: TimeInterval(event.at)) + let rule = rules[index] + live.markBoundedRuleClaimed(claim, at: rules[index].lastHit ?? Date()) + publish(advancingGeneration: false) + guard let db else { + completion(false) + return + } + db.queue.async { [weak self, weak db] in + guard let db else { + completion(false) + return + } + do { + try db.recordBoundedRuleDecision(rule: rule, event: event) + let events = (try? db.ruleEvents(limit: 300)) ?? [] + completion(true) + Task { @MainActor in + self?.events = events + self?.version += 1 + } + } catch { + completion(false) + } + } + } + + /// Conservatively retires a bounded claim whose XPC reply was lost. The extension may already have accepted it; + /// saving the spent count before authorization completes prevents reconnect or relaunch from reusing it. + func retireBoundedClaim( + _ claim: RuleClaim, + completion: @escaping @Sendable (Bool) -> Void + ) { + guard acceptsDecision( + ruleID: claim.ruleID, + session: claim.session, + generation: claim.generation + ), + let index = rules.firstIndex(where: { $0.id == claim.ruleID }), + rules[index].maxHits > 0 else { + completion(false) + return + } + rules[index].hits = max(rules[index].hits, rules[index].maxHits) + rules[index].lastHit = Date() + let rule = rules[index] + live.markBoundedRuleSpent(claim) + publish(advancingGeneration: false) + guard let db else { + completion(false) + return + } + db.queue.async { [weak db] in + guard let db else { + completion(false) + return + } + do { + try db.saveRule(rule) + completion(true) + } catch { + completion(false) + } + } } /// The feed for one rule, for its own page. @@ -167,15 +280,40 @@ final class RuleStore: ObservableObject { // MARK: Telling everyone else - private func publish() { + private func acceptsDecision( + ruleID: UUID, + session: String, + generation: UInt64 + ) -> Bool { + session == Self.session && + generation <= publicationGeneration && + generation >= (ruleInvalidatedAt[ruleID] ?? 0) + } + + private func publish(advancingGeneration: Bool = true) { version += 1 - live.replace(rules, pausedUntil: pausedUntil) + if advancingGeneration { + publicationGeneration &+= 1 + for ruleID in pendingRuleInvalidations { + ruleInvalidatedAt[ruleID] = publicationGeneration + } + } + live.replace( + rules, + pausedUntil: pausedUntil, + generation: publicationGeneration, + resetHitCounts: pendingHitResets, + ruleInvalidatedAt: ruleInvalidatedAt + ) onChange(ruleSet) + pendingHitResets.removeAll() + pendingRuleInvalidations.removeAll() } var ruleSet: RuleSet { RuleSet(rules: rules.filter { $0.enabled && $0.isComplete && $0.engine == .flow }, - pausedUntil: pausedUntil, session: Self.session) + generation: publicationGeneration, pausedUntil: pausedUntil, session: Self.session, + resetHitCounts: pendingHitResets, ruleInvalidatedAt: ruleInvalidatedAt) } /// The rules the inspection proxy carries out: the ones that name a path or a method, which no filter can see. @@ -185,15 +323,132 @@ final class RuleStore: ObservableObject { /// The rule list as anything off the main actor sees it. Held behind a lock rather than an actor because the /// proxy asks while it is framing a request, and a request that has to wait for an actor hop is a request that /// arrives late. +struct LiveFlowRuleDecision: Sendable { + var paused: Bool + var decision: RuleDecision + var externalClaim: RuleClaim? +} + +final class LiveRuleClaimAuthority: @unchecked Sendable { + private let lock = NSLock() + private weak var source: ExtensionTrafficSource? + + func replace(_ source: ExtensionTrafficSource?) { + lock.lock(); defer { lock.unlock() } + self.source = source + } + + func claim(_ claim: RuleClaim, completion: @escaping @Sendable (Bool?) -> Void) { + lock.lock() + let source = source + lock.unlock() + source?.claimRule(claim, completion: completion) ?? completion(nil) + } +} + final class LiveRules: @unchecked Sendable { private let lock = NSLock() private var rules: [Rule] = [] private var pausedUntil: Date? + private var generation: UInt64 = 0 + private var ruleInvalidatedAt: [UUID: UInt64] = [:] + private var usesExternalClaimAuthority = false + + /// Atomically hands finite-rule counting to the extension and returns the exact whole-list publication it should + /// install. Any local claim completed before this lock is reflected in the transferred hit count; every decision + /// after it carries a generation-bound external claim instead of spending independently. + func beginExternalClaimAuthority(with set: RuleSet) -> RuleSet { + lock.lock(); defer { lock.unlock() } + usesExternalClaimAuthority = true + var synchronized = set + synchronized.rules = set.rules.map { published in + guard let runtime = rules.first(where: { $0.id == published.id }), + runtime.hits > published.hits else { return published } + var merged = published + merged.hits = runtime.hits + merged.lastHit = runtime.lastHit + return merged + } + return synchronized + } - func replace(_ rules: [Rule], pausedUntil: Date?) { + func replace( + _ rules: [Rule], + pausedUntil: Date?, + generation: UInt64 = 0, + resetHitCounts: Set = [], + ruleInvalidatedAt: [UUID: UInt64] = [:] + ) { lock.lock(); defer { lock.unlock() } - self.rules = rules + self.rules = rules.map { published in + guard !resetHitCounts.contains(published.id), + let runtime = self.rules.first(where: { $0.id == published.id }), + runtime.hits > published.hits else { + return published + } + var merged = published + merged.hits = runtime.hits + merged.lastHit = runtime.lastHit + return merged + } self.pausedUntil = pausedUntil + self.generation = generation + self.ruleInvalidatedAt.merge(ruleInvalidatedAt, uniquingKeysWith: max) + for ruleID in resetHitCounts { + self.ruleInvalidatedAt[ruleID] = generation + } + } + + /// The flow-level answer for an already named proxied connection. Pause and rules are read under one lock so a + /// connection cannot combine the pause from one publication with the rule list from another. Bounded rules can + /// be previewed without spending them while the extension atomically claims the decision for both data paths. + func flowDecision( + _ facts: FlowFacts, + now: Date = Date() + ) -> LiveFlowRuleDecision { + lock.lock(); defer { lock.unlock() } + let consumeBounded = !usesExternalClaimAuthority + let paused = pausedUntil.map { now < $0 } ?? false + let decision = RuleBook.decide(facts, rules: rules, now: now, session: RuleStore.session, + pausedUntil: pausedUntil) + if let rule = decision.rule, + (rule.maxHits == 0 || consumeBounded), + let index = rules.firstIndex(where: { $0.id == rule.id }) { + rules[index].hits += 1 + rules[index].lastHit = now + } + return LiveFlowRuleDecision( + paused: paused, + decision: decision, + externalClaim: decision.rule.flatMap { rule in + rule.maxHits > 0 && !consumeBounded + ? RuleClaim(ruleID: rule.id, session: RuleStore.session, generation: generation) + : nil + } + ) + } + + private func acceptsDecision(_ claim: RuleClaim) -> Bool { + claim.session == RuleStore.session && + claim.generation <= generation && + claim.generation >= (ruleInvalidatedAt[claim.ruleID] ?? 0) + } + + func markBoundedRuleClaimed(_ claim: RuleClaim, at now: Date = Date()) { + lock.lock(); defer { lock.unlock() } + guard acceptsDecision(claim), + let index = rules.firstIndex(where: { $0.id == claim.ruleID }), + rules[index].maxHits > 0 else { return } + rules[index].hits = min(rules[index].maxHits, rules[index].hits + 1) + rules[index].lastHit = now + } + + func markBoundedRuleSpent(_ claim: RuleClaim) { + lock.lock(); defer { lock.unlock() } + guard acceptsDecision(claim), + let index = rules.firstIndex(where: { $0.id == claim.ruleID }), + rules[index].maxHits > 0 else { return } + rules[index].hits = max(rules[index].hits, rules[index].maxHits) } /// The rules that would answer a request right now. Timing is judged here, at the moment the request arrives, diff --git a/Flowlight/App/BoundedHTTPDataTransport.swift b/Flowlight/App/BoundedHTTPDataTransport.swift new file mode 100644 index 00000000..0c42e637 --- /dev/null +++ b/Flowlight/App/BoundedHTTPDataTransport.swift @@ -0,0 +1,159 @@ +import Foundation + +final class BoundedHTTPDataTransport: NSObject, URLSessionDataDelegate, @unchecked Sendable { + enum TransportError: Error, Equatable { + case redirectRejected + case responseTooLarge + case invalidResponse + } + + private struct Pending { + var response: URLResponse? + var data = Data() + var continuation: CheckedContinuation<(Data, URLResponse), Error> + } + + private final class TaskHandle: @unchecked Sendable { + private let lock = NSLock() + private var task: URLSessionTask? + private var cancelled = false + + func install(_ task: URLSessionTask, cancel: (Int) -> Void) { + lock.lock() + if cancelled { + lock.unlock() + cancel(task.taskIdentifier) + task.cancel() + return + } + self.task = task + lock.unlock() + } + + func cancel(_ finish: (Int) -> Void) { + lock.lock() + cancelled = true + let task = task + lock.unlock() + guard let task else { return } + finish(task.taskIdentifier) + task.cancel() + } + } + + private let maximumBytes: Int + private let allowedOrigin: URL + private let lock = NSLock() + private var pending: [Int: Pending] = [:] + private var session: URLSession! + + init(allowedOrigin: URL, maximumBytes: Int, requestTimeout: TimeInterval = 20, + resourceTimeout: TimeInterval = 30, protocolClasses: [AnyClass]? = nil) { + self.allowedOrigin = allowedOrigin + self.maximumBytes = maximumBytes + super.init() + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = protocolClasses ?? configuration.protocolClasses + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.timeoutIntervalForRequest = requestTimeout + configuration.timeoutIntervalForResource = resourceTimeout + session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) + } + + deinit { session.invalidateAndCancel() } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let handle = TaskHandle() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let task = session.dataTask(with: request) + lock.lock() + pending[task.taskIdentifier] = Pending(continuation: continuation) + lock.unlock() + handle.install(task) { [weak self] taskID in + self?.finish(taskID: taskID, result: .failure(CancellationError())) + } + task.resume() + } + } onCancel: { + handle.cancel { [weak self] taskID in + self?.finish(taskID: taskID, result: .failure(CancellationError())) + } + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + guard request.url.map(sameOrigin) == true else { + completionHandler(nil) + finish(taskID: task.taskIdentifier, result: .failure(TransportError.redirectRejected)) + return + } + completionHandler(request) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + if response.expectedContentLength > Int64(maximumBytes) { + completionHandler(.cancel) + finish(taskID: dataTask.taskIdentifier, result: .failure(TransportError.responseTooLarge)) + return + } + lock.lock() + if var value = pending[dataTask.taskIdentifier] { + value.response = response + pending[dataTask.taskIdentifier] = value + } + lock.unlock() + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + lock.lock() + guard var value = pending[dataTask.taskIdentifier] else { lock.unlock(); return } + let nextCount = value.data.count + data.count + guard nextCount <= maximumBytes else { + lock.unlock() + dataTask.cancel() + finish(taskID: dataTask.taskIdentifier, result: .failure(TransportError.responseTooLarge)) + return + } + value.data.append(data) + pending[dataTask.taskIdentifier] = value + lock.unlock() + } + + func urlSession(_ session: URLSession, task: URLSessionTask, + didCompleteWithError error: Error?) { + lock.lock() + guard let value = pending.removeValue(forKey: task.taskIdentifier) else { lock.unlock(); return } + lock.unlock() + if let error { value.continuation.resume(throwing: error); return } + guard let response = value.response else { + value.continuation.resume(throwing: TransportError.invalidResponse) + return + } + value.continuation.resume(returning: (value.data, response)) + } + + private func finish(taskID: Int, result: Result<(Data, URLResponse), Error>) { + lock.lock() + let value = pending.removeValue(forKey: taskID) + lock.unlock() + value?.continuation.resume(with: result) + } + + private func sameOrigin(_ candidate: URL) -> Bool { + candidate.scheme?.lowercased() == allowedOrigin.scheme?.lowercased() + && candidate.host?.lowercased() == allowedOrigin.host?.lowercased() + && effectivePort(candidate) == effectivePort(allowedOrigin) + } + + private func effectivePort(_ url: URL) -> Int? { + url.port ?? (url.scheme?.lowercased() == "https" ? 443 : url.scheme?.lowercased() == "http" ? 80 : nil) + } +} diff --git a/Flowlight/App/FlowlightApp.swift b/Flowlight/App/FlowlightApp.swift index 4d146b04..ea8cec29 100644 --- a/Flowlight/App/FlowlightApp.swift +++ b/Flowlight/App/FlowlightApp.swift @@ -3,7 +3,39 @@ import SwiftUI /// Handles the one thing SwiftUI's scene lifecycle doesn't give cleanly: a hook that runs as the app quits, used to /// strip the proxy routing Flowlight persisted into agents' settings files so a clean quit never leaves one pointing /// at a proxy that is about to stop. A crash skips this; `ProxyJanitor` covers that. +@MainActor final class AppDelegate: NSObject, NSApplicationDelegate { + private static var didFinishLaunching = false + private static var didStart = false + private static var startup: (() -> Void)? + + static func installStartup(_ action: @escaping () -> Void) { + guard !didStart else { return } + startup = action + runStartupIfReady() + } + + func applicationDidFinishLaunching(_ notification: Notification) { + Self.didFinishLaunching = true + Self.runStartupIfReady() + } + + private static func runStartupIfReady() { + guard didFinishLaunching, !didStart, let startup else { return } + didStart = true + self.startup = nil + startup() + } + + #if DEBUG + static func resetStartupForTests(didFinishLaunching: Bool = false) { + guard FlowlightApp.runningTests else { return } + self.didFinishLaunching = didFinishLaunching + didStart = false + startup = nil + } + #endif + func applicationWillTerminate(_ notification: Notification) { // Not in tests (they share this process), and not in a demo or screenshot run: that is a throwaway instance // with its own database, and it must never strip the enforcement the real running app owns. @@ -41,11 +73,38 @@ struct FlowlightApp: App { } } - @StateObject private var monitor = TrafficMonitor() - @StateObject private var extensionManager = ExtensionManager() - @StateObject private var nav = AppNavigation() - @StateObject private var updater = UpdateChecker() - @StateObject private var focus = FocusStore() + @StateObject private var monitor: TrafficMonitor + @StateObject private var extensionManager: ExtensionManager + @StateObject private var nav: AppNavigation + @StateObject private var updater: UpdateChecker + @StateObject private var focus: FocusStore + + init() { + let monitor = TrafficMonitor() + let extensionManager = ExtensionManager() + let nav = AppNavigation() + let updater = UpdateChecker() + let focus = FocusStore() + _monitor = StateObject(wrappedValue: monitor) + _extensionManager = StateObject(wrappedValue: extensionManager) + _nav = StateObject(wrappedValue: nav) + _updater = StateObject(wrappedValue: updater) + _focus = StateObject(wrappedValue: focus) + + // SwiftUI may restore a background-only launch with no window and without rendering the menu-bar label yet. + // Core services belong to the application lifecycle, not to either presentation path. + guard !Self.runningTests else { return } + AppDelegate.installStartup { + monitor.start() + monitor.applyFocus(focus.scope) + extensionManager.refresh() + extensionManager.matchExtensionToApp { check in + guard check.repairing else { return } + Task { @MainActor in monitor.extensionVersionRepairing(check) } + } + updater.start() + } + } var body: some Scene { WindowGroup("Flowlight", id: "main") { @@ -56,21 +115,6 @@ struct FlowlightApp: App { .environmentObject(updater) .environmentObject(focus) .frame(minWidth: 1000, minHeight: 660) - .task { - // The unit tests are hosted in this app, so every test run launches it for real: capture - // starts, nettop is spawned every second, XPC connects, the update check fires. None of that - // belongs in a test run, and it is where CI intermittently hangs. - guard !FlowlightApp.runningTests else { return } - monitor.start() - monitor.applyFocus(focus.scope) - extensionManager.refresh() - extensionManager.matchExtensionToApp { check in - guard check.repairing else { return } - Task { @MainActor in monitor.extensionVersionRepairing(check) } - } - updater.start() - } - .onChange(of: focus.scope) { _, scope in monitor.applyFocus(scope) } .onChange(of: backgroundOnly, initial: true) { _, on in Self.applyActivationPolicy(backgroundOnly: on) } .onAppear { Self.windowsChanged() } .onDisappear { Self.windowsChanged() } @@ -134,7 +178,11 @@ struct FlowlightApp: App { .environmentObject(updater) .environmentObject(focus) } label: { - MenuBarLabel().environmentObject(monitor).environmentObject(updater) + MenuBarLabel() + .environmentObject(monitor) + .environmentObject(extensionManager) + .environmentObject(updater) + .environmentObject(focus) } Settings { @@ -146,11 +194,15 @@ struct FlowlightApp: App { struct MenuBarLabel: View { @EnvironmentObject var monitor: TrafficMonitor @EnvironmentObject var updater: UpdateChecker + @EnvironmentObject var focus: FocusStore @Environment(\.openWindow) private var openWindow + @AppStorage(AnomalySettings.Keys.backgroundOnly) private var backgroundOnly = false @AppStorage(AnomalySettings.Keys.menuBarRates) private var showRates = true var body: some View { label + .onChange(of: focus.scope) { _, scope in monitor.applyFocus(scope) } + .onChange(of: backgroundOnly, initial: true) { _, _ in FlowlightApp.windowsChanged() } // The menu bar item always exists, so a found update opens its window even with the main window closed. .onChange(of: updater.showWindow) { _, show in if show { openWindow(id: "update"); updater.showWindow = false } diff --git a/Flowlight/App/HTTPDataTransport.swift b/Flowlight/App/HTTPDataTransport.swift new file mode 100644 index 00000000..aa50f9ff --- /dev/null +++ b/Flowlight/App/HTTPDataTransport.swift @@ -0,0 +1,4 @@ +import Foundation + +/// Request-level seam shared by update clients. It preserves conditional headers and final response metadata in tests. +typealias HTTPDataTransport = @Sendable (URLRequest) async throws -> (Data, URLResponse) diff --git a/Flowlight/App/TrafficMonitor.swift b/Flowlight/App/TrafficMonitor.swift index 99063aa6..2c5f152d 100644 --- a/Flowlight/App/TrafficMonitor.swift +++ b/Flowlight/App/TrafficMonitor.swift @@ -55,7 +55,15 @@ final class TrafficMonitor: ObservableObject { /// Everything Flowlight has been told to block or allow, and what those rules have done. let rules = RuleStore() /// Official managed destination lists, kept separate from human-authored rules and disabled by default. - let rulePacks = RulePackStore() + let rulePacks: RulePackStore + /// The exact extension-committed generation, readable from the inspection proxy's queue. + private let liveRulePacks = LiveRulePacks() + /// The active extension is the one atomic authority for bounded rules across direct and proxied flows. + private let liveRuleClaims = LiveRuleClaimAuthority() + /// App-local pack refusals use their own ordered, retryable delivery session. + private var proxyRulePackEvents: RulePackEventDelivery! + /// Signed official catalog bootstrap, cache re-verification, and bounded refresh state. + private(set) var rulePackCatalog: RulePackCatalogUpdater! /// Which tools each agent may use, which is a different question from which hosts it may reach. let guardrails = GuardrailStore() /// Advisory inspection plugins: official built-ins first, installed third-party packs later. @@ -75,6 +83,8 @@ final class TrafficMonitor: ObservableObject { } let activity = ActivityMonitor() private let engine: AnomalyEngine + private let trafficSourceFactory: (() -> TrafficSource)? + private let catalogTransportInjected: Bool private var source: TrafficSource? private var timers: [Timer] = [] @@ -96,13 +106,20 @@ final class TrafficMonitor: ObservableObject { private var perSecond: [Int64: [String: (name: String, path: String, counters: FlowCounters, topDest: String, topBytes: Int64)]] = [:] private var sessionPerApp: [String: FlowCounters] = [:] - init() { + init(databaseURL: URL? = nil, + catalogTransport: HTTPDataTransport? = nil, + trafficSourceFactory: (() -> TrafficSource)? = nil) { AnomalySettings.registerDefaults() LocalRiskSettings.registerDefaults() InspectionBudget.registerDefaults() mode = CaptureMode(rawValue: UserDefaults.standard.string(forKey: AnomalySettings.Keys.captureMode) ?? "") ?? .nettop + self.trafficSourceFactory = trafficSourceFactory + catalogTransportInjected = catalogTransport != nil + rulePacks = RulePackStore(currentAppVersion: ExtensionManager.appVersion) do { - if DemoData.isEnabled { + if let databaseURL { + db = try TrafficDatabase(url: databaseURL) + } else if DemoData.isEnabled { DemoData.resetDatabase() db = try TrafficDatabase(url: DemoData.databaseURL) } else { @@ -116,6 +133,28 @@ final class TrafficMonitor: ObservableObject { } readDB = (try? TrafficDatabase(url: db.url, readOnly: true)) ?? db engine = AnomalyEngine(db: db, activity: activity) + proxyRulePackEvents = RulePackEventDelivery { [weak rulePacks] batch, claim, completion in + Task { @MainActor in + rulePacks?.record( + batch, + claim: claim, + acknowledgeDuringClear: false, + completion: completion + ) ?? completion(false) + } + } + rulePackCatalog = RulePackCatalogUpdater( + db: db, + store: rulePacks, + currentAppVersion: ExtensionManager.appVersion, + bundledCatalog: { + guard let url = Bundle.main.url(forResource: "official", withExtension: "json") else { + throw RulePackCatalogPersistenceError.invalidReceipt + } + return try Data(contentsOf: url) + }, + transport: catalogTransport + ) IPOwnerLookup.shared.onResolved = { [db] ip, owner in db.async { try $0.saveOwner(ip: ip, owner) } } @@ -132,10 +171,17 @@ final class TrafficMonitor: ObservableObject { } private var started = false + private var rulePackCatalogReady = false func start() { guard !started else { return } started = true + // A signed build may have an older extension process still enforcing its disconnect grace or holding a + // buffered finite-rule decision. Reserve the extension as the only counter from process startup, even when + // this run begins in sampler mode; unavailable claims fail open rather than reusing a bounded allowance. + if ExtensionManager.isEntitled { + _ = rules.live.beginExternalClaimAuthority(with: rules.ruleSet) + } if DemoData.isEnabled { IPOwnerLookup.shared.isEnabled = { false } IPOwnerLookup.shared.preload(DemoData.owners()) @@ -153,17 +199,46 @@ final class TrafficMonitor: ObservableObject { } inspection.attach(db: db) Task { await localRisk.recover() } - rules.onChange = { [weak self] set in self?.source?.setRules(set) } + rules.onChange = { [weak self] set in + guard let self else { return } + if self.source is ExtensionTrafficSource { + self.source?.setRules(self.rules.live.beginExternalClaimAuthority(with: set)) + } else { + self.source?.setRules(set) + } + } rules.attach(db: db) rulePacks.onChange = { [weak self] snapshot in guard let self else { return } + self.liveRulePacks.replace(snapshot: nil) if self.source is ExtensionTrafficSource { self.rulePacks.markApplying() } else { self.rulePacks.updateStatus(self.rulePacks.hasEnabledPacks ? .watchingOnly : .off) } self.source?.setRulePacks(snapshot) } - rulePacks.attach(db: db) - // A rule that names a path is carried out by the proxy, which reads its list fresh on every connection. + // The proxy reads one lock-backed publication for rules and one for the extension-committed pack generation. inspection.requestRules = { [live = rules.live] in live.requestRules() } + inspection.flowRuleDecision = { [live = rules.live] facts in + live.flowDecision(facts) + } + inspection.claimFlowRule = { [liveRuleClaims] claim, completion in + liveRuleClaims.claim(claim, completion: completion) + } + inspection.flowRuleClaimed = { [weak rules] claim, event, completion in + Task { @MainActor in + rules?.recordBoundedClaim(claim, event: event, completion: completion) ?? completion(false) + } + } + inspection.flowRuleSpent = { [weak rules] claim, completion in + Task { @MainActor in + rules?.retireBoundedClaim(claim, completion: completion) ?? completion(false) + } + } + inspection.rulePackMatch = { [liveRulePacks] host, bundleID in + liveRulePacks.match(host: host, bundleID: bundleID) + } + inspection.onRulePackRefusal = { [weak proxyRulePackEvents] event, completion in + proxyRulePackEvents?.enqueue(event, completion: completion) ?? completion(false) + } inspection.onRuleRefusal = { [weak self] event in Task { @MainActor in self?.rules.record([event]) } } @@ -195,7 +270,20 @@ final class TrafficMonitor: ObservableObject { if defaults.bool(forKey: AnomalySettings.Keys.notifications) || defaults.bool(forKey: UpdateChecker.Keys.automatic) { Notifier.requestAuthorization() } - startSource() + // Establish the signed catalog before loading saved consent and exceptions. Starting capture waits for both, + // so a corrupt higher cache can never briefly enforce bundled content, and enabled packs never omit exceptions. + Task { [weak self] in + guard let self else { return } + await self.rulePackCatalog.bootstrap() + self.rulePacks.attach(db: self.db) { [weak self] in + guard let self else { return } + self.rulePackCatalogReady = true + self.startSource() + let mayUseNetwork = self.catalogTransportInjected + || !DemoData.isEnabled && !FlowlightApp.runningTests + self.rulePackCatalog.startScheduling(networkingEnabled: mayUseNetwork) + } + } db.async { [engine] db in IPOwnerLookup.shared.preload(try db.loadOwners()) for ip in try db.unownedIPs(since: Date().addingTimeInterval(-86400)) { IPOwnerLookup.shared.owner(for: ip) } @@ -342,13 +430,17 @@ final class TrafficMonitor: ObservableObject { } private func startSource() { + guard rulePackCatalogReady else { return } + liveRulePacks.replace(snapshot: nil) source?.stop() let newSource: TrafficSource // A build without the entitlement can never reach the extension; capture with the sampler rather than // sitting there recording nothing. let canUseExtension = ExtensionManager.isEntitled var fallbackNote: String? - if DemoData.isEnabled { + if let trafficSourceFactory { + newSource = trafficSourceFactory() + } else if DemoData.isEnabled { newSource = DemoTrafficSource() } else if mode == .networkExtension, canUseExtension, !extensionFellBack { newSource = ExtensionTrafficSource() @@ -377,8 +469,11 @@ final class TrafficMonitor: ObservableObject { extensionSource.onRulePackEvents = { [weak self] batch, completion in self?.recordRulePackEvents(batch, completion: completion) ?? completion(false) } - extensionSource.onRulePackStatus = { [weak self] result in - Task { @MainActor in self?.updateRulePackStatus(result) } + extensionSource.onRulePackStatus = { [weak self, weak extensionSource] result in + Task { @MainActor in + guard let self, let extensionSource, self.source === extensionSource else { return } + self.updateRulePackStatus(result) + } } extensionSource.onRepairRequested = { [weak self] in Task { @MainActor in self?.repairExtensionVersion() } @@ -388,6 +483,16 @@ final class TrafficMonitor: ObservableObject { } } source = newSource + let extensionSource = newSource as? ExtensionTrafficSource + liveRuleClaims.replace(extensionSource) + let sourceRuleSet: RuleSet + if extensionSource != nil { + sourceRuleSet = rules.live.beginExternalClaimAuthority(with: rules.ruleSet) + } else { + // External finite-rule ownership is session-sticky. The extension deliberately enforces for a short + // disconnect grace and may still hold an undelivered spend; resuming a local counter would permit reuse. + sourceRuleSet = rules.ruleSet + } status = fallbackNote ?? L("Starting %@…", newSource.displayName) if started { updatePacketCapture() } newSource.start(sink: { [weak self] batches in @@ -396,7 +501,7 @@ final class TrafficMonitor: ObservableObject { Task { @MainActor in self?.status = fallbackNote ?? message } }) pushEnforcement() - newSource.setRules(rules.ruleSet) + newSource.setRules(sourceRuleSet) newSource.setRulePacks(rulePacks.snapshot) if rulePacks.hasEnabledPacks, !(newSource is ExtensionTrafficSource) { rulePacks.updateStatus(.watchingOnly) @@ -406,24 +511,41 @@ final class TrafficMonitor: ObservableObject { private func updateRulePackStatus(_ result: RulePackApplyResult) { switch result.status { case .accepted: + liveRulePacks.replace(snapshot: nil) rulePacks.updateStatus(.applying) case .committed: - rulePacks.updateStatus(.enforcing) + let desired = rulePacks.snapshot + liveRulePacks.commit(snapshot: desired, activeDigest: result.activeDigest) + if desired?.digest == result.activeDigest { + rulePacks.updateStatus(.enforcing) + } else { + rulePacks.updateStatus(.failed("The filter acknowledged a different rule pack snapshot.")) + } case .cleared: + liveRulePacks.replace(snapshot: nil) rulePacks.updateStatus(.off) case .incompatible: + liveRulePacks.replace(snapshot: nil) rulePacks.updateStatus(.incompatible(result.detail)) case .invalid, .stale: + liveRulePacks.replace(snapshot: nil) rulePacks.updateStatus(.failed(result.detail)) } } - nonisolated private func recordRulePackEvents(_ batch: RulePackEventBatch, - completion: @escaping (Bool) -> Void) { - Task { @MainActor in + nonisolated private func recordRulePackEvents( + _ batch: RulePackEventBatch, + completion: @escaping @Sendable (Bool) -> Void + ) { + Task { @MainActor [weak self] in + guard let self else { + completion(false) + return + } self.rulePacks.record(batch) { [weak self] committed in - if committed { self?.dataVersion += 1 } completion(committed) + guard committed else { return } + Task { @MainActor [weak self] in self?.dataVersion += 1 } } } } @@ -703,16 +825,24 @@ final class TrafficMonitor: ObservableObject { } func clearAllData() { - db.async { [weak self] db in - try db.clearAll() - Task { @MainActor in - self?.sessionPerApp.removeAll() - self?.perSecond.removeAll() - self?.sessionIn = 0 - self?.sessionOut = 0 - self?.rulePacks.clearActivity() - self?.dataVersion += 1 - self?.refreshAlertCount() + rulePacks.beginActivityClear() + proxyRulePackEvents.invalidate { [weak self, db] in + Task { @MainActor [weak self, db] in + guard let self else { return } + do { + try await db.asyncResult { try $0.clearAll() } + self.sessionPerApp.removeAll() + self.perSecond.removeAll() + self.sessionIn = 0 + self.sessionOut = 0 + self.rulePacks.finishActivityClear(succeeded: true) + self.rules.clearPublishedEventsAfterDurableDeletion() + self.dataVersion += 1 + self.refreshAlertCount() + } catch { + self.rulePacks.finishActivityClear(succeeded: false) + self.lastError = L("Couldn't delete the captured data: %@", error.localizedDescription) + } } } } diff --git a/Flowlight/App/UpdateChecker.swift b/Flowlight/App/UpdateChecker.swift index fe3c15b9..a44a2f38 100644 --- a/Flowlight/App/UpdateChecker.swift +++ b/Flowlight/App/UpdateChecker.swift @@ -121,7 +121,7 @@ final class UpdateChecker: ObservableObject { private var timer: Timer? private var downloadTask: Task? /// Fixture seam for release metadata only; downloads always use the private ephemeral session below. - private let releaseTransport: (@Sendable (URL) async throws -> (Data, URLResponse))? + private let releaseTransport: HTTPDataTransport? private lazy var session: URLSession = { let config = URLSessionConfiguration.ephemeral @@ -132,7 +132,7 @@ final class UpdateChecker: ObservableObject { }() init(bundle: Bundle = .main, - releaseTransport: (@Sendable (URL) async throws -> (Data, URLResponse))? = nil) { + releaseTransport: HTTPDataTransport? = nil) { currentVersion = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0" repository = bundle.object(forInfoDictionaryKey: "FLUpdateRepository") as? String ?? "xinbetween/flowlight" self.releaseTransport = releaseTransport @@ -189,11 +189,15 @@ final class UpdateChecker: ObservableObject { state = .checking do { let url = URL(string: "https://api.github.com/repos/\(repository)/releases/latest")! + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Flowlight/\(currentVersion)", forHTTPHeaderField: "User-Agent") + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") let (data, response): (Data, URLResponse) if let releaseTransport { - (data, response) = try await releaseTransport(url) + (data, response) = try await releaseTransport(request) } else { - (data, response) = try await session.data(from: url) + (data, response) = try await session.data(for: request) } let code = (response as? HTTPURLResponse)?.statusCode ?? 0 guard code == 200 else { throw code == 404 ? UpdateError.noRelease : UpdateError.badResponse(code) } diff --git a/Flowlight/Capture/ExtensionManager.swift b/Flowlight/Capture/ExtensionManager.swift index 45d48bc8..a17ceb84 100644 --- a/Flowlight/Capture/ExtensionManager.swift +++ b/Flowlight/Capture/ExtensionManager.swift @@ -94,7 +94,8 @@ final class ExtensionManager: NSObject, ObservableObject { guard let bundle = Bundle(url: url), let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, !version.isEmpty else { return appVersion } - return version + let build = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "" + return build.isEmpty ? version : "\(version)/\(build)" } /// Whether this build carries the content-filter entitlement (false for ad-hoc/local builds). @@ -245,6 +246,12 @@ final class ExtensionManager: NSObject, ObservableObject { } extension ExtensionManager: OSSystemExtensionRequestDelegate { + nonisolated private static func versionIdentity(_ properties: OSSystemExtensionProperties) -> String { + let version = properties.bundleShortVersion + let build = properties.bundleVersion + return build.isEmpty ? version : "\(version)/\(build)" + } + nonisolated func request(_ request: OSSystemExtensionRequest, actionForReplacingExtension existing: OSSystemExtensionProperties, withExtension ext: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction { .replace @@ -254,10 +261,10 @@ extension ExtensionManager: OSSystemExtensionRequestDelegate { nonisolated func request(_ request: OSSystemExtensionRequest, foundProperties properties: [OSSystemExtensionProperties]) { // Only the enabled copies count. A disabled one holds nothing and replacing it would quietly switch the // filter back on for someone who turned it off on purpose. - let installed = properties.filter { $0.isEnabled && !$0.isUninstalling }.map(\.bundleShortVersion) + let installed = properties.filter { $0.isEnabled && !$0.isUninstalling }.map(Self.versionIdentity) // Copies macOS has terminated and is holding until a restart. They still occupy the one content-filter // slot, so the enabled build is installed without being the one running. - let awaitingReboot = properties.filter(\.isUninstalling).map(\.bundleShortVersion) + let awaitingReboot = properties.filter(\.isUninstalling).map(Self.versionIdentity) Task { @MainActor in let expected = Self.expectedExtensionVersion self.versionCheckResult = VersionCheck(installed: installed, expectedVersion: expected, diff --git a/Flowlight/Capture/ExtensionRecovery.swift b/Flowlight/Capture/ExtensionRecovery.swift index cad58d23..e7c69c9d 100644 --- a/Flowlight/Capture/ExtensionRecovery.swift +++ b/Flowlight/Capture/ExtensionRecovery.swift @@ -119,19 +119,25 @@ enum ExtensionVersion { } /// Dotted numeric comparison, so "0.5" and "0.5.0" are one version and "0.10.0" comes after "0.9.0" — which - /// plain string comparison gets backwards. A component that isn't a number counts as zero, and anything - /// trailing it is ignored, which is what makes the "0.5.0/8" form `systemextensionsctl` prints work here too. + /// plain string comparison gets backwards. `systemextensionsctl` appends the bundle build after `/`; compare it + /// when both sides provide one, while retaining compatibility with older callers that know only the short version. static func compare(_ lhs: String, _ rhs: String) -> ComparisonResult { - let left = components(lhs), right = components(rhs) - for index in 0.. [Int] { - version.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 } + private static func identity(_ value: String) -> (version: [Int], build: Int?) { + let parts = value.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) + let version = parts[0].split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 } + let build = parts.count == 2 ? Int(parts[1].prefix(while: \.isNumber)) : nil + return (version, build) } } diff --git a/Flowlight/Capture/ExtensionTrafficSource.swift b/Flowlight/Capture/ExtensionTrafficSource.swift index 7b9fad4f..97b37639 100644 --- a/Flowlight/Capture/ExtensionTrafficSource.swift +++ b/Flowlight/Capture/ExtensionTrafficSource.swift @@ -1,5 +1,33 @@ import Foundation +/// Serializes access to the extension's one mutable transfer buffer. Repeating the active epoch is redundant; a newer +/// epoch is coalesced and starts only after the active attempt reaches a terminal callback. +struct RulePackPushGate { + private(set) var activeEpoch: UInt64? + private var hasPendingNewerEpoch = false + + mutating func begin(epoch: UInt64) -> Bool { + guard let activeEpoch else { + self.activeEpoch = epoch + return true + } + if activeEpoch != epoch { hasPendingNewerEpoch = true } + return false + } + + mutating func finish(epoch: UInt64) -> Bool? { + guard activeEpoch == epoch else { return nil } + activeEpoch = nil + defer { hasPendingNewerEpoch = false } + return hasPendingNewerEpoch + } + + mutating func reset() { + activeEpoch = nil + hasPendingNewerEpoch = false + } +} + /// Receives per-second batches from the system extension over XPC. final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @unchecked Sendable { var displayName: String { L("Network Extension") } @@ -28,11 +56,13 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u /// Connections a rule decided, refusals and relaxations alike. var onRuleDecisions: (([RuleEvent]) -> Void)? /// Aggregated managed-pack decisions. These never enter the ordinary block-alert path. - var onRulePackEvents: ((RulePackEventBatch, @escaping (Bool) -> Void) -> Void)? + var onRulePackEvents: ((RulePackEventBatch, @escaping @Sendable (Bool) -> Void) -> Void)? var onRulePackStatus: ((RulePackApplyResult) -> Void)? private var rulePackSnapshot: RulePackSnapshot? /// Invalidates every callback from a superseded desired snapshot or XPC connection. private var rulePackPushEpoch: UInt64 = 0 + /// The extension owns one mutable transfer buffer, so overlapping handshakes must never race to reset it. + private var rulePackPushGate = RulePackPushGate() /// The escalation ladder for a connection that keeps failing. Only touched on the main thread, like /// `connection`, because a decision made from two queues at once is no decision at all. private var recovery = ExtensionRecovery() @@ -79,6 +109,7 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u connection = nil superseded?.invalidate() rulePackPushEpoch &+= 1 + rulePackPushGate.reset() let connection = NSXPCConnection(machServiceName: FlowlightConstants.machServiceName, options: []) connection.remoteObjectInterface = NSXPCInterface(with: FlowlightProviderXPC.self) @@ -110,8 +141,8 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u : L("Connected to filter extension %@ — no traffic from it yet", version)) self.startHeartbeat() self.reportPackStatus(.init(status: .accepted, detail: "Synchronizing managed packs")) - // Initial synchronization is ordered: explicit user allows must exist before either managed packs or - // AgentPolicy can block. The extension stays fail-open until the final pack commit/clear acknowledges. + // Initial synchronization is ordered: explicit user allows exist before AgentPolicy is enabled. + // Managed packs remain independently cleared until their own commit or clear acknowledges. self.pushRules { self.pushEnforcement { self.pushRulePacks() @@ -216,8 +247,14 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u private func pushEnforcement(completion: (() -> Void)? = nil) { guard let connection else { return } let payload = enforcement.isEmpty ? Data() : BlockCoding.encode(enforcement) - let proxy = connection.remoteObjectProxyWithErrorHandler { _ in } as? FlowlightProviderXPC - proxy?.setEnforcement(payload: payload) { _ in + let proxy = connection.remoteObjectProxyWithErrorHandler { [weak self, weak connection] error in + self?.scheduleReconnect(L("Extension unreachable: %@", error.localizedDescription), from: connection) + } as? FlowlightProviderXPC + guard let proxy else { + scheduleReconnect(L("Extension unreachable: %@", "policy update"), from: connection) + return + } + proxy.setEnforcement(payload: payload) { [weak self, weak connection] _ in DispatchQueue.main.async { [weak self, weak connection] in guard let self, let connection, self.connection === connection else { return } completion?() @@ -235,16 +272,56 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u private func pushRules(completion: (() -> Void)? = nil) { guard let connection else { return } - let payload = ruleSet.rules.isEmpty && ruleSet.pausedUntil == nil ? Data() : BlockCoding.encode(ruleSet) - let proxy = connection.remoteObjectProxyWithErrorHandler { _ in } as? FlowlightProviderXPC - proxy?.setRules(payload: payload) { _ in + let sentGeneration = ruleSet.generation + let payload = BlockCoding.encode(ruleSet) + let proxy = connection.remoteObjectProxyWithErrorHandler { [weak self, weak connection] error in + self?.scheduleReconnect(L("Extension unreachable: %@", error.localizedDescription), from: connection) + } as? FlowlightProviderXPC + guard let proxy else { + scheduleReconnect(L("Extension unreachable: %@", "rule update"), from: connection) + return + } + proxy.setRules(payload: payload) { [weak self, weak connection] _ in DispatchQueue.main.async { [weak self, weak connection] in guard let self, let connection, self.connection === connection else { return } + guard self.ruleSet.generation == sentGeneration else { + // The initial handshake chains enforcement and packs from this reply. If a newer whole-list + // publication overtook it, restart that chain with the current generation instead of stranding + // the source half-synchronized. + if completion != nil { + self.pushRules(completion: completion) + } + return + } completion?() } } } + func claimRule(_ claim: RuleClaim, completion: @escaping @Sendable (Bool?) -> Void) { + DispatchQueue.main.async { [weak self] in + guard let self, let connection = self.connection else { + completion(nil) + return + } + let proxy = connection.remoteObjectProxyWithErrorHandler { _ in completion(nil) } + as? FlowlightProviderXPC + guard let proxy else { + completion(nil) + return + } + proxy.claimRule(payload: BlockCoding.encode(claim)) { [weak self, weak connection] result in + DispatchQueue.main.async { [weak self, weak connection] in + guard let self, let connection, self.connection === connection else { + completion(nil) + return + } + completion(result < 0 ? nil : result == 1) + } + } + } + } + func setRulePacks(_ snapshot: RulePackSnapshot?) { DispatchQueue.main.async { [weak self] in guard let self else { return } @@ -257,40 +334,76 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u private func pushRulePacks() { guard let connection else { return } let epoch = rulePackPushEpoch + guard rulePackPushGate.begin(epoch: epoch) else { return } let desiredGeneration = rulePackSnapshot?.generation let proxy = connection.remoteObjectProxyWithErrorHandler { [weak self, weak connection] error in DispatchQueue.main.async { - guard let self, let connection, self.connection === connection, - self.rulePackPushEpoch == epoch else { return } - self.reportPackStatus(.init(status: .invalid, detail: error.localizedDescription)) + guard let self, let connection, self.connection === connection else { return } + appLog.error("pack sync XPC failed epoch \(epoch, privacy: .public): \(error.localizedDescription, privacy: .public)") + self.finishRulePackPush( + epoch: epoch, + status: .init(status: .invalid, detail: error.localizedDescription) + ) } } as? FlowlightProviderXPC - proxy?.rulePackHello(appProtocol: Int(RulePackSnapshot.protocolVersion)) { [weak self, weak connection] data in + guard let proxy else { + appLog.error("pack sync proxy unavailable epoch \(epoch, privacy: .public)") + finishRulePackPush( + epoch: epoch, + status: .init(status: .invalid, detail: "The extension pack interface is unavailable.") + ) + return + } + proxy.rulePackHello(appProtocol: Int(RulePackSnapshot.protocolVersion)) { [weak self, weak connection] (data: Data) in DispatchQueue.main.async { - guard let self, let connection, self.connection === connection, - self.rulePackPushEpoch == epoch, - self.rulePackSnapshot?.generation == desiredGeneration, - let handshake = BlockCoding.decode(RulePackHandshake.self, from: data) else { return } + guard let self, let connection, self.connection === connection else { return } + guard self.rulePackPushEpoch == epoch, + self.rulePackSnapshot?.generation == desiredGeneration else { + self.finishRulePackPush(epoch: epoch) + return + } + guard let handshake = BlockCoding.decode(RulePackHandshake.self, from: data) else { + self.finishRulePackPush( + epoch: epoch, + status: .init(status: .invalid, detail: "The extension returned an unreadable pack handshake.") + ) + return + } guard handshake.protocolVersion == RulePackSnapshot.protocolVersion else { - self.reportPackStatus(.init(status: .incompatible, activeDigest: handshake.activeDigest, - detail: "The installed extension does not support this pack format.")) + self.finishRulePackPush( + epoch: epoch, + status: .init( + status: .incompatible, + activeDigest: handshake.activeDigest, + detail: "The installed extension does not support this pack format." + ) + ) return } guard let snapshot = self.rulePackSnapshot else { let request = RulePackClearRequest(expectedActiveGeneration: handshake.activeGeneration) - proxy?.clearRulePacks(payload: BlockCoding.encode(request)) { [weak self, weak connection] data in + proxy.clearRulePacks(payload: BlockCoding.encode(request)) { [weak self, weak connection] data in DispatchQueue.main.async { - guard let self, let connection, self.connection === connection, - self.rulePackPushEpoch == epoch, self.rulePackSnapshot == nil else { return } - self.decodePackStatus(data) + guard let self, let connection, self.connection === connection else { return } + guard self.rulePackPushEpoch == epoch, self.rulePackSnapshot == nil else { + self.finishRulePackPush(epoch: epoch) + return + } + self.finishRulePackPush(epoch: epoch, statusData: data) } } return } guard handshake.activeDigest != snapshot.digest else { - self.reportPackStatus(.init(status: .committed, generation: handshake.activeGeneration, - activeDigest: handshake.activeDigest, - activePacks: snapshot.configurations.filter(\.enabled).count)) + self.finishRulePackPush( + epoch: epoch, + status: .init( + status: .committed, + generation: handshake.activeGeneration, + activeDigest: handshake.activeDigest, + activePacks: snapshot.configurations.filter(\.enabled).count + ) + ) return } let payload = BlockCoding.encode(snapshot) @@ -302,13 +415,16 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u generation: snapshot.generation, digest: snapshot.digest, totalBytes: payload.count, chunks: chunks.count, expectedActiveGeneration: handshake.activeGeneration) - proxy?.beginRulePack(payload: BlockCoding.encode(header)) { [weak self, weak connection] reply in + proxy.beginRulePack(payload: BlockCoding.encode(header)) { [weak self, weak connection] reply in DispatchQueue.main.async { - guard let self, let connection, self.connection === connection, - self.rulePackPushEpoch == epoch, - self.rulePackSnapshot?.generation == snapshot.generation else { return } + guard let self, let connection, self.connection === connection else { return } + guard self.rulePackPushEpoch == epoch, + self.rulePackSnapshot?.generation == snapshot.generation else { + self.finishRulePackPush(epoch: epoch) + return + } guard BlockCoding.decode(RulePackApplyResult.self, from: reply)?.status == .accepted else { - self.decodePackStatus(reply) + self.finishRulePackPush(epoch: epoch, statusData: reply) return } self.appendPackChunks(chunks, index: 0, snapshot: snapshot, epoch: epoch, @@ -320,29 +436,44 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u } private func appendPackChunks(_ chunks: [Data], index: Int, snapshot: RulePackSnapshot, epoch: UInt64, - connection: NSXPCConnection, proxy: FlowlightProviderXPC?) { + connection: NSXPCConnection, proxy: FlowlightProviderXPC) { guard self.connection === connection, rulePackPushEpoch == epoch, - rulePackSnapshot?.generation == snapshot.generation else { return } + rulePackSnapshot?.generation == snapshot.generation else { + finishRulePackPush(epoch: epoch) + return + } guard index < chunks.count else { - proxy?.commitRulePack(generation: snapshot.generation.uuidString) { [weak self, weak connection] data in + proxy.commitRulePack(generation: snapshot.generation.uuidString) { [weak self, weak connection] data in DispatchQueue.main.async { - guard let self, let connection, self.connection === connection, - self.rulePackPushEpoch == epoch, - self.rulePackSnapshot?.generation == snapshot.generation else { return } - self.decodePackStatus(data) + guard let self, let connection, self.connection === connection else { return } + guard self.rulePackPushEpoch == epoch, + self.rulePackSnapshot?.generation == snapshot.generation else { + self.finishRulePackPush(epoch: epoch) + return + } + self.finishRulePackPush(epoch: epoch, statusData: data) } } return } - proxy?.appendRulePack(generation: snapshot.generation.uuidString, index: index, payload: chunks[index]) { + proxy.appendRulePack(generation: snapshot.generation.uuidString, index: index, payload: chunks[index]) { [weak self, weak connection] accepted in DispatchQueue.main.async { - guard let self, let connection, self.connection === connection, - self.rulePackPushEpoch == epoch, - self.rulePackSnapshot?.generation == snapshot.generation else { return } + guard let self, let connection, self.connection === connection else { return } + guard self.rulePackPushEpoch == epoch, + self.rulePackSnapshot?.generation == snapshot.generation else { + self.finishRulePackPush(epoch: epoch) + return + } guard accepted else { - self.reportPackStatus(.init(status: .invalid, generation: snapshot.generation, - detail: "The extension rejected pack chunk \(index + 1).")) + self.finishRulePackPush( + epoch: epoch, + status: .init( + status: .invalid, + generation: snapshot.generation, + detail: "The extension rejected pack chunk \(index + 1)." + ) + ) return } self.appendPackChunks(chunks, index: index + 1, snapshot: snapshot, epoch: epoch, @@ -351,25 +482,42 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u } } - private func decodePackStatus(_ data: Data) { - guard let status = BlockCoding.decode(RulePackApplyResult.self, from: data) else { - reportPackStatus(.init(status: .invalid, detail: "The extension returned an unreadable pack status.")) - return + private func finishRulePackPush(epoch: UInt64, statusData: Data) { + let status = BlockCoding.decode(RulePackApplyResult.self, from: statusData) + ?? .init(status: .invalid, detail: "The extension returned an unreadable pack status.") + finishRulePackPush(epoch: epoch, status: status) + } + + private func finishRulePackPush(epoch: UInt64, status: RulePackApplyResult? = nil) { + guard let hasPendingNewerEpoch = rulePackPushGate.finish(epoch: epoch) else { return } + if let status { + if status.status == .stale { + reportPackStatus( + .init( + status: .accepted, + generation: status.generation, + activeDigest: status.activeDigest, + detail: status.detail + ) + ) + } else { + reportPackStatus(status) + } } - if status.status == .stale { - reportPackStatus(.init(status: .accepted, generation: status.generation, - activeDigest: status.activeDigest, detail: status.detail)) - DispatchQueue.main.async { [weak self] in self?.pushRulePacks() } - } else { - reportPackStatus(status) + if hasPendingNewerEpoch || status?.status == .stale { + pushRulePacks() } } private func reportPackStatus(_ status: RulePackApplyResult) { - DispatchQueue.main.async { [weak self] in self?.onRulePackStatus?(status) } + if Thread.isMainThread { + onRulePackStatus?(status) + } else { + DispatchQueue.main.async { [weak self] in self?.onRulePackStatus?(status) } + } } - func rulePackEvents(payload: Data, reply: @escaping (Bool) -> Void) { + func rulePackEvents(payload: Data, reply: @escaping @Sendable (Bool) -> Void) { guard let batch = BlockCoding.decode(RulePackEventBatch.self, from: payload), let onRulePackEvents else { reply(false) diff --git a/Flowlight/Capture/TrafficSource.swift b/Flowlight/Capture/TrafficSource.swift index 4b7f190f..25810737 100644 --- a/Flowlight/Capture/TrafficSource.swift +++ b/Flowlight/Capture/TrafficSource.swift @@ -12,6 +12,8 @@ protocol TrafficSource: AnyObject { /// The rule list this source should carry out. Same story as `setEnforcement`: only the Network Extension is /// in the data path, and the Rules screen says so per rule rather than pretending. func setRules(_ rules: RuleSet) + /// Atomically spends one bounded flow rule. Nil means this source cannot authoritatively claim it. + func claimRule(_ claim: RuleClaim, completion: @escaping @Sendable (Bool?) -> Void) /// One complete managed-pack generation. Nil clears packs. Other capture sources ignore it and the UI reports /// watching-only instead of claiming that the setting is enforced. func setRulePacks(_ snapshot: RulePackSnapshot?) @@ -20,6 +22,7 @@ protocol TrafficSource: AnyObject { extension TrafficSource { func setEnforcement(_ policies: [AgentPolicy]) {} func setRules(_ rules: RuleSet) {} + func claimRule(_ claim: RuleClaim, completion: @escaping @Sendable (Bool?) -> Void) { completion(nil) } func setRulePacks(_ snapshot: RulePackSnapshot?) {} } diff --git a/Flowlight/Inspection/InspectionController.swift b/Flowlight/Inspection/InspectionController.swift index 463e01bd..da1a073c 100644 --- a/Flowlight/Inspection/InspectionController.swift +++ b/Flowlight/Inspection/InspectionController.swift @@ -1,6 +1,56 @@ import AppKit import Foundation +/// Socket ownership can lag connection acceptance briefly while libproc publishes the new descriptor. Keep the +/// authorization wait bounded, but give app-scoped rules and managed packs more than one scheduling instant to find it. +enum ProxyOwnerLookup { + typealias Scheduler = @Sendable ( + TimeInterval, + @escaping @Sendable () -> Void + ) -> Void + + static let retryDelays: [TimeInterval] = [0.01, 0.025, 0.05] + + static func resolve( + lookup: @escaping @Sendable () -> InspectionRecorder.Owner, + completion: @escaping @Sendable (InspectionRecorder.Owner) -> Void + ) { + resolve( + delays: retryDelays[...], + lookup: lookup, + schedule: { delay, work in + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + delay, + execute: work + ) + }, + completion: completion + ) + } + + static func resolve( + delays: ArraySlice, + lookup: @escaping @Sendable () -> InspectionRecorder.Owner, + schedule: @escaping Scheduler, + completion: @escaping @Sendable (InspectionRecorder.Owner) -> Void + ) { + let owner = lookup() + guard owner.bundleID.isEmpty, + let delay = delays.first else { + completion(owner) + return + } + schedule(delay) { + resolve( + delays: delays.dropFirst(), + lookup: lookup, + schedule: schedule, + completion: completion + ) + } + } +} + /// HTTPS inspection: an opt-in mode, off by default, that decrypts the traffic of apps routed through Flowlight's /// local proxy using a certificate authority created on this Mac. Everything it records stays in the local database. @MainActor @@ -64,8 +114,33 @@ final class InspectionController: ObservableObject { /// which only the proxy can see. Read fresh on every connection, so a rule written now applies to the next /// request rather than the next launch. nonisolated(unsafe) var requestRules: @Sendable () -> [Rule] = { [] } - /// Called with every request a rule refused, ready to be recorded in the violations feed. + /// Called with every request or proxied connection a rule refused, ready for the violations feed. nonisolated(unsafe) var onRuleRefusal: @Sendable (RuleEvent) -> Void = { _ in } + /// One atomic flow-level rule answer, including whether the global escape hatch is active. + nonisolated(unsafe) var flowRuleDecision: @Sendable (FlowFacts) -> LiveFlowRuleDecision = { + _ in LiveFlowRuleDecision(paused: false, decision: RuleDecision(verdict: .allow, rule: nil), externalClaim: nil) + } + /// Bounded rules are previewed locally, then atomically claimed by the active enforcement source. + nonisolated(unsafe) var claimFlowRule: @Sendable ( + RuleClaim, + @escaping @Sendable (Bool?) -> Void + ) -> Void = { _, completion in completion(nil) } + /// Persists an accepted bounded claim and its event before the proxy applies the answer. + nonisolated(unsafe) var flowRuleClaimed: @Sendable ( + RuleClaim, + RuleEvent, + @escaping @Sendable (Bool) -> Void + ) -> Void = { _, _, completion in completion(false) } + nonisolated(unsafe) var flowRuleSpent: @Sendable ( + RuleClaim, + @escaping @Sendable (Bool) -> Void + ) -> Void = { _, completion in completion(false) } + /// The exact extension-committed managed-pack generation, queried with the original proxied app identity. + nonisolated(unsafe) var rulePackMatch: @Sendable (String, String) -> RulePackMatch? = { _, _ in nil } + /// Persists a proxy pack refusal before the proxy acts. False means fail open: no invisible refusal. + nonisolated(unsafe) var onRulePackRefusal: @Sendable (RulePackEvent, @escaping @Sendable (Bool) -> Void) -> Void = { + _, completion in completion(false) + } /// The guardrails in force, read fresh per connection like the rules. nonisolated(unsafe) var guardrails: @Sendable () -> [Guardrail] = { [] } /// Called with every tool a guardrail took away, and every call it refused. @@ -104,8 +179,10 @@ final class InspectionController: ObservableObject { // A rule refusing a request is answered by the same machinery that gives a mock its canned response, and // it goes first: a block someone wrote has to outrank a mock they left switched on. let answersFor = { [weak self, recorder, proxy] (host: String, clientPort: UInt16) -> [MockRule] in + let destination = Self.proxyDestination(host) var refusals = (self?.requestRules() ?? []).filter { - $0.action == .block && ($0.destination.isEmpty || AgentPolicy.matches($0.destination, host: host, ip: "")) + $0.action == .block && ($0.destination.isEmpty + || AgentPolicy.matches($0.destination, host: destination.host, ip: destination.ip)) } if refusals.contains(where: { !$0.app.isEmpty }) { // Only then is it worth asking who opened this connection: naming an app is the uncommon case, @@ -119,6 +196,14 @@ final class InspectionController: ObservableObject { return refusals.map { $0.asRefusal(host: host) } + MockRules.mocks(mockRules(), host: host) } proxy.mockRules = { host, clientPort in answersFor(host, clientPort) } + proxy.authorizeConnection = { [weak self, recorder, proxy] host, port, clientPort, answer in + guard let self else { answer(true); return } + ProxyOwnerLookup.resolve( + lookup: { recorder.owner(clientPort: clientPort, proxyPort: proxy.port) } + ) { owner in + self.authorizeProxyConnection(host: host, port: port, owner: owner, answer: answer) + } + } // Guardrails read and change whole requests, which is a different job from matching a URL, so they get // their own hook rather than being squeezed into the answer machinery. proxy.interventions = { [weak self, recorder, proxy] host, clientPort in @@ -456,24 +541,27 @@ final class InspectionController: ObservableObject { proxy.stop() return } - Task.detached(priority: .userInitiated) { - var failure: String? - var trustChanged = false - // Trust first: it has its own dialog, and there's no point changing the proxy if it's refused. - if needsTrustChange { - do { try certificate.setTrusted(on); trustChanged = true } catch { failure = error.localizedDescription } - } - if failure == nil, needsProxyChange { - var error: NSDictionary? - NSAppleScript(source: script)?.executeAndReturnError(&error) - failure = error.flatMap { e -> String? in - (e[NSAppleScript.errorNumber] as? Int) == -128 ? "cancelled" - : (e[NSAppleScript.errorMessage] as? String ?? L("authorization failed")) + Task { + let failure = await Task.detached(priority: .userInitiated) { + var failure: String? + var trustChanged = false + // Trust first: it has its own dialog, and there's no point changing the proxy if it's refused. + if needsTrustChange { + do { try certificate.setTrusted(on); trustChanged = true } catch { failure = error.localizedDescription } } - // Don't leave the certificate trusted for a proxy that was never set up. - if failure != nil, trustChanged { try? certificate.setTrusted(!on) } - } - await MainActor.run { self.finishSetup(on: on, failure: failure) } + if failure == nil, needsProxyChange { + var error: NSDictionary? + NSAppleScript(source: script)?.executeAndReturnError(&error) + failure = error.flatMap { e -> String? in + (e[NSAppleScript.errorNumber] as? Int) == -128 ? "cancelled" + : (e[NSAppleScript.errorMessage] as? String ?? L("authorization failed")) + } + // Don't leave the certificate trusted for a proxy that was never set up. + if failure != nil, trustChanged { try? certificate.setTrusted(!on) } + } + return failure + }.value + finishSetup(on: on, failure: failure) } } @@ -770,6 +858,113 @@ final class InspectionController: ObservableObject { // MARK: Matching + nonisolated private func authorizeProxyConnection( + host: String, + port: Int, + owner: InspectionRecorder.Owner, + answer: @escaping @Sendable (Bool) -> Void + ) { + guard !owner.bundleID.isEmpty else { answer(true); return } + let facts = Self.proxyFlowFacts(host: host, port: port, owner: owner) + guard !BlockRules.isExempt(facts) else { answer(true); return } + let ruled = flowRuleDecision(facts) + guard !ruled.paused else { answer(true); return } + if let rule = ruled.decision.rule { + authorizeProxyRule( + rule, + externalClaim: ruled.externalClaim, + facts: facts, + owner: owner, + answer: answer + ) + return + } + guard let match = rulePackMatch(host, owner.bundleID) else { answer(true); return } + let now = Int64(Date().timeIntervalSince1970) + let event = RulePackEvent(bucket: now / 60 * 60, packID: match.packID, + packVersion: match.packVersion, entryID: match.entry.id, + category: match.entry.category, match: match.entry.match, action: .block, + count: 1, firstAt: now, lastAt: now, bundleID: owner.bundleID, + appName: owner.appName, agentKey: facts.agentKey, + agentName: owner.agentName ?? owner.appName, host: match.matchedHost, + matchedHost: match.entry.host, port: facts.port) + onRulePackRefusal(event) { committed in answer(!committed) } + } + + nonisolated private func authorizeProxyRule( + _ rule: Rule, + externalClaim: RuleClaim?, + facts: FlowFacts, + owner: InspectionRecorder.Owner, + answer: @escaping @Sendable (Bool) -> Void + ) { + let event = RuleEvent(at: Int64(Date().timeIntervalSince1970), ruleID: rule.id, + ruleSession: externalClaim?.session, + ruleGeneration: externalClaim?.generation, + action: rule.action, engine: .flow, agentKey: facts.agentKey, + agentName: owner.agentName ?? owner.appName, appName: owner.appName, + bundleID: owner.bundleID, host: facts.host, ip: facts.ip, port: facts.port) + let finish: @Sendable (Rule) -> Void = { [onRuleRefusal] rule in + onRuleRefusal(event) + answer(rule.action != .block) + } + guard let externalClaim else { + finish(rule) + return + } + claimFlowRule(externalClaim) { [weak self] claimed in + guard let self else { answer(true); return } + guard claimed == true else { + // Another flow spent this rule first. An uncertain reply may also mean this exact claim committed + // before XPC failed; retire it conservatively so reconnect cannot reuse a bounded allowance. Both + // paths fail this connection open unless a definitive "already spent" result supports re-decision. + self.flowRuleSpent(externalClaim) { persisted in + guard persisted, claimed == false else { + answer(true) + return + } + let next = self.flowRuleDecision(facts) + guard !next.paused, let nextRule = next.decision.rule else { + answer(true) + return + } + self.authorizeProxyRule( + nextRule, + externalClaim: next.externalClaim, + facts: facts, + owner: owner, + answer: answer + ) + } + return + } + self.flowRuleClaimed(externalClaim, event) { persisted in + answer(persisted ? rule.action != .block : true) + } + } + } + + nonisolated static func proxyDestination(_ host: String) -> (host: String, ip: String) { + guard let address = HostCanonicalizer.address(host) else { return (host, "") } + return ("", address) + } + + nonisolated static func proxyFlowFacts( + host: String, + port: Int, + owner: InspectionRecorder.Owner + ) -> FlowFacts { + let destination = proxyDestination(host) + return FlowFacts( + agentKey: owner.agent ?? owner.bundleID, + bundleID: owner.bundleID, + host: destination.host, + ip: destination.ip, + port: UInt16(clamping: port), + hostSettled: true + ) + } + nonisolated static func matches(host: String, patterns: [String]) -> Bool { let host = host.lowercased() return patterns.contains { raw in diff --git a/Flowlight/Inspection/InspectionProxy.swift b/Flowlight/Inspection/InspectionProxy.swift index 3777928f..b46e3d7e 100644 --- a/Flowlight/Inspection/InspectionProxy.swift +++ b/Flowlight/Inspection/InspectionProxy.swift @@ -57,6 +57,12 @@ final class InspectionProxy: @unchecked Sendable { private var pinned: [String: Date] = [:] weak var observer: ProxyObserver? + /// Decides whether a client connection may reach its origin. The answer can wait for a durable refusal record; + /// routing resumes on the proxy queue and no upstream socket exists before an allow answer. + var authorizeConnection: @Sendable (_ host: String, _ port: Int, _ clientPort: UInt16, + _ answer: @escaping @Sendable (Bool) -> Void) -> Void = { + _, _, _, answer in answer(true) + } /// Decides whether to decrypt a CONNECT to `host` from the client at `clientPort`. May answer asynchronously /// (it can look up the owning process); the proxy continues on its own queue. var shouldInspect: (_ host: String, _ clientPort: UInt16, _ answer: @escaping (Bool) -> Void) -> Void = { _, _, answer in answer(true) } @@ -150,20 +156,26 @@ final class InspectionProxy: @unchecked Sendable { } if head.method == "CONNECT" { guard let (host, port) = head.authority else { respond(client, status: "400 Bad Request"); return } - let notPinned = pinned[host].map { $0 < Date() } ?? true - let proceed: (Bool) -> Void = { [weak self] inspect in + authorizeConnection(host, port, clientPort) { [weak self] allowed in self?.queue.async { - client.send(content: Data("HTTP/1.1 200 Connection Established\r\n\r\n".utf8), completion: .contentProcessed { error in - guard let self, error == nil else { client.cancel(); return } - if inspect && notPinned { - self.bridge(client, host: host, port: port, clientPort: clientPort, early: rest) - } else { - self.tunnel(client, host: host, port: port, clientPort: clientPort, early: rest) + guard let self else { client.cancel(); return } + guard allowed else { self.respond(client, status: "403 Forbidden"); return } + let notPinned = self.pinned[host].map { $0 < Date() } ?? true + let proceed: (Bool) -> Void = { [weak self] inspect in + self?.queue.async { + client.send(content: Data("HTTP/1.1 200 Connection Established\r\n\r\n".utf8), completion: .contentProcessed { error in + guard let self, error == nil else { client.cancel(); return } + if inspect && notPinned { + self.bridge(client, host: host, port: port, clientPort: clientPort, early: rest) + } else { + self.tunnel(client, host: host, port: port, clientPort: clientPort, early: rest) + } + }) } - }) + } + if notPinned { self.shouldInspect(host, clientPort, proceed) } else { proceed(false) } } } - if notPinned { shouldInspect(host, clientPort, proceed) } else { proceed(false) } } else if head.target.hasPrefix("/") { if head.target.hasPrefix("/proxy.pac") { let body = Data(pacScript().utf8) @@ -172,13 +184,20 @@ final class InspectionProxy: @unchecked Sendable { respond(client, status: "404 Not Found") } } else if let url = URL(string: head.target), url.scheme == "http", let host = url.host { - // Plain HTTP through the proxy: forward it and watch it like an inspected flow. + // Plain HTTP through the proxy: decide before an origin socket exists, then forward and observe it. let port = url.port ?? 80 - let flow = ProxyFlow(host: host, port: port, clientPort: clientPort, inspected: true, scheme: "http") - var request = head.originForm() - request.append(rest) - let upstream = NWConnection(host: .init(host), port: .init(integerLiteral: UInt16(clamping: port)), using: Self.direct(.tcp)) - relay(client: client, upstream: upstream, flow: flow, firstClientBytes: request) + authorizeConnection(host, port, clientPort) { [weak self] allowed in + self?.queue.async { + guard let self else { client.cancel(); return } + guard allowed else { self.respond(client, status: "403 Forbidden"); return } + let flow = ProxyFlow(host: host, port: port, clientPort: clientPort, inspected: true, scheme: "http") + var request = head.originForm() + request.append(rest) + let upstream = NWConnection(host: .init(host), port: .init(integerLiteral: UInt16(clamping: port)), + using: Self.direct(.tcp)) + self.relay(client: client, upstream: upstream, flow: flow, firstClientBytes: request) + } + } } else { respond(client, status: "400 Bad Request") } diff --git a/Flowlight/Inspection/InspectionRecorder.swift b/Flowlight/Inspection/InspectionRecorder.swift index 333d0d91..686a50c5 100644 --- a/Flowlight/Inspection/InspectionRecorder.swift +++ b/Flowlight/Inspection/InspectionRecorder.swift @@ -305,6 +305,9 @@ final class InspectionRecorder: ProxyObserver, @unchecked Sendable { if let hit = owners[clientPort], Date().timeIntervalSince(hit.at) < 30 { ownersLock.unlock(); return hit.owner } ownersLock.unlock() let owner = lookupOwner(clientPort: clientPort, proxyPort: proxyPort) + // The socket can reach the listener just before libproc publishes it. Do not turn that timing miss into a + // 30-second cached identity-less owner; authorization gets a bounded chance to ask again. + guard !owner.bundleID.isEmpty else { return owner } ownersLock.lock() if owners.count > 2000 { owners.removeAll() } owners[clientPort] = (owner, Date()) diff --git a/Flowlight/Localization/de.lproj/Localizable.strings b/Flowlight/Localization/de.lproj/Localizable.strings index 44fd7ca6..e196cd3d 100644 --- a/Flowlight/Localization/de.lproj/Localizable.strings +++ b/Flowlight/Localization/de.lproj/Localizable.strings @@ -1166,7 +1166,23 @@ "%lld blocked" = "%lld blockiert"; "%lld exceptions" = "%lld Ausnahmen"; "Review…" = "Prüfen…"; -"Official · Built into Flowlight · pack v%@ · %@" = "Offiziell · In Flowlight integriert · Paket v%@ · %@"; +"Official pack updates" = "Updates für offizielle Pakete"; +"Flowlight checks its fixed HTTPS feed for publisher-signed destination lists. No traffic, pack settings, or device identifier is sent." = "Flowlight prüft seinen fest vorgegebenen HTTPS-Feed auf vom Herausgeber signierte Ziellisten. Es werden keine Verkehrsdaten, Paketeinstellungen oder Gerätekennungen gesendet."; +"Automatic updates" = "Automatische Updates"; +"Sequence %lld" = "Sequenz %lld"; +"Last verified %@" = "Zuletzt verifiziert %@"; +"Next check %@" = "Nächste Prüfung %@"; +"Official source: %@" = "Offizielle Quelle: %@"; +"Publisher: Flowlight · signer %@ · catalog digest %@" = "Herausgeber: Flowlight · Unterzeichner %@ · Katalog-Digest %@"; +"Verified and current" = "Verifiziert und aktuell"; +"Verified last-known-good is stale" = "Der verifizierte letzte gültige Stand ist veraltet"; +"Recovery required" = "Wiederherstellung erforderlich"; +"Check failed · using last-known-good" = "Prüfung fehlgeschlagen · letzter gültiger Stand wird verwendet"; +"Check failed" = "Prüfung fehlgeschlagen"; +"Official · Verified official update · pack v%@ · %@" = "Offiziell · Verifiziertes offizielles Update · Paket v%@ · %@"; +"Official · Bundled baseline · pack v%@ · %@" = "Offiziell · Mitgelieferte Basisversion · Paket v%@ · %@"; +"Apply failed" = "Anwenden fehlgeschlagen"; +"This pack changed while it was open. Reopen it to review the new content before saving." = "Dieses Paket wurde geändert, während es geöffnet war. Öffnen Sie es erneut, um den neuen Inhalt vor dem Speichern zu überprüfen."; "Applying…" = "Wird angewendet…"; "Watching only" = "Nur beobachten"; "Blocking" = "Blockieren"; @@ -1717,3 +1733,4 @@ "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; "Suggests reviewed actions" = "Suggests reviewed actions"; "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; +"Couldn't delete the captured data: %@" = "Die erfassten Daten konnten nicht gelöscht werden: %@"; diff --git a/Flowlight/Localization/en.lproj/Localizable.strings b/Flowlight/Localization/en.lproj/Localizable.strings index 181501db..76c31ba6 100644 --- a/Flowlight/Localization/en.lproj/Localizable.strings +++ b/Flowlight/Localization/en.lproj/Localizable.strings @@ -1098,7 +1098,23 @@ "%lld blocked" = "%lld blocked"; "%lld exceptions" = "%lld exceptions"; "Review…" = "Review…"; -"Official · Built into Flowlight · pack v%@ · %@" = "Official · Built into Flowlight · pack v%@ · %@"; +"Official pack updates" = "Official pack updates"; +"Flowlight checks its fixed HTTPS feed for publisher-signed destination lists. No traffic, pack settings, or device identifier is sent." = "Flowlight checks its fixed HTTPS feed for publisher-signed destination lists. No traffic, pack settings, or device identifier is sent."; +"Automatic updates" = "Automatic updates"; +"Sequence %lld" = "Sequence %lld"; +"Last verified %@" = "Last verified %@"; +"Next check %@" = "Next check %@"; +"Official source: %@" = "Official source: %@"; +"Publisher: Flowlight · signer %@ · catalog digest %@" = "Publisher: Flowlight · signer %@ · catalog digest %@"; +"Verified and current" = "Verified and current"; +"Verified last-known-good is stale" = "Verified last-known-good is stale"; +"Recovery required" = "Recovery required"; +"Check failed · using last-known-good" = "Check failed · using last-known-good"; +"Check failed" = "Check failed"; +"Official · Verified official update · pack v%@ · %@" = "Official · Verified official update · pack v%@ · %@"; +"Official · Bundled baseline · pack v%@ · %@" = "Official · Bundled baseline · pack v%@ · %@"; +"Apply failed" = "Apply failed"; +"This pack changed while it was open. Reopen it to review the new content before saving." = "This pack changed while it was open. Reopen it to review the new content before saving."; "Applying…" = "Applying…"; "Watching only" = "Watching only"; "Blocking" = "Blocking"; @@ -1716,3 +1732,4 @@ "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; "Suggests reviewed actions" = "Suggests reviewed actions"; "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; +"Couldn't delete the captured data: %@" = "Couldn't delete the captured data: %@"; diff --git a/Flowlight/Localization/es.lproj/Localizable.strings b/Flowlight/Localization/es.lproj/Localizable.strings index ad808b80..0337a9f0 100644 --- a/Flowlight/Localization/es.lproj/Localizable.strings +++ b/Flowlight/Localization/es.lproj/Localizable.strings @@ -1166,7 +1166,23 @@ "%lld blocked" = "%lld bloqueadas"; "%lld exceptions" = "%lld excepciones"; "Review…" = "Revisar…"; -"Official · Built into Flowlight · pack v%@ · %@" = "Oficial · Integrado en Flowlight · paquete v%@ · %@"; +"Official pack updates" = "Actualizaciones de paquetes oficiales"; +"Flowlight checks its fixed HTTPS feed for publisher-signed destination lists. No traffic, pack settings, or device identifier is sent." = "Flowlight consulta su fuente HTTPS fija para obtener listas de destinos firmadas por el editor. No se envían datos de tráfico, ajustes de paquetes ni identificadores del dispositivo."; +"Automatic updates" = "Actualizaciones automáticas"; +"Sequence %lld" = "Secuencia %lld"; +"Last verified %@" = "Última verificación %@"; +"Next check %@" = "Próxima comprobación %@"; +"Official source: %@" = "Fuente oficial: %@"; +"Publisher: Flowlight · signer %@ · catalog digest %@" = "Editor: Flowlight · firmante %@ · huella del catálogo %@"; +"Verified and current" = "Verificado y actualizado"; +"Verified last-known-good is stale" = "La última versión válida verificada está desactualizada"; +"Recovery required" = "Se requiere recuperación"; +"Check failed · using last-known-good" = "Error de comprobación · se usa la última versión válida"; +"Check failed" = "Error de comprobación"; +"Official · Verified official update · pack v%@ · %@" = "Oficial · Actualización oficial verificada · paquete v%@ · %@"; +"Official · Bundled baseline · pack v%@ · %@" = "Oficial · Versión base incluida · paquete v%@ · %@"; +"Apply failed" = "Error al aplicar"; +"This pack changed while it was open. Reopen it to review the new content before saving." = "Este paquete cambió mientras estaba abierto. Vuelva a abrirlo para revisar el contenido nuevo antes de guardarlo."; "Applying…" = "Aplicando…"; "Watching only" = "Solo vigilancia"; "Blocking" = "Bloqueando"; @@ -1717,3 +1733,4 @@ "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; "Suggests reviewed actions" = "Suggests reviewed actions"; "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; +"Couldn't delete the captured data: %@" = "No se pudieron eliminar los datos capturados: %@"; diff --git a/Flowlight/Localization/fr.lproj/Localizable.strings b/Flowlight/Localization/fr.lproj/Localizable.strings index 767e2253..f4b11181 100644 --- a/Flowlight/Localization/fr.lproj/Localizable.strings +++ b/Flowlight/Localization/fr.lproj/Localizable.strings @@ -1166,7 +1166,23 @@ "%lld blocked" = "%lld bloquées"; "%lld exceptions" = "%lld exceptions"; "Review…" = "Examiner…"; -"Official · Built into Flowlight · pack v%@ · %@" = "Officiel · Intégré à Flowlight · pack v%@ · %@"; +"Official pack updates" = "Mises à jour des packs officiels"; +"Flowlight checks its fixed HTTPS feed for publisher-signed destination lists. No traffic, pack settings, or device identifier is sent." = "Flowlight consulte son flux HTTPS fixe pour obtenir des listes de destinations signées par l’éditeur. Aucune donnée de trafic, aucun réglage de pack ni aucun identifiant d’appareil n’est envoyé."; +"Automatic updates" = "Mises à jour automatiques"; +"Sequence %lld" = "Séquence %lld"; +"Last verified %@" = "Dernière vérification %@"; +"Next check %@" = "Prochaine vérification %@"; +"Official source: %@" = "Source officielle : %@"; +"Publisher: Flowlight · signer %@ · catalog digest %@" = "Éditeur : Flowlight · signataire %@ · empreinte du catalogue %@"; +"Verified and current" = "Vérifié et à jour"; +"Verified last-known-good is stale" = "La dernière version valide vérifiée est obsolète"; +"Recovery required" = "Récupération requise"; +"Check failed · using last-known-good" = "Échec de la vérification · utilisation de la dernière version valide"; +"Check failed" = "Échec de la vérification"; +"Official · Verified official update · pack v%@ · %@" = "Officiel · Mise à jour officielle vérifiée · pack v%@ · %@"; +"Official · Bundled baseline · pack v%@ · %@" = "Officiel · Version de référence intégrée · pack v%@ · %@"; +"Apply failed" = "Échec de l’application"; +"This pack changed while it was open. Reopen it to review the new content before saving." = "Ce pack a changé pendant qu’il était ouvert. Rouvrez-le pour examiner le nouveau contenu avant de l’enregistrer."; "Applying…" = "Application…"; "Watching only" = "Surveillance seulement"; "Blocking" = "Blocage"; @@ -1717,3 +1733,4 @@ "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; "Suggests reviewed actions" = "Suggests reviewed actions"; "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; +"Couldn't delete the captured data: %@" = "Impossible de supprimer les données capturées : %@"; diff --git a/Flowlight/Localization/it.lproj/Localizable.strings b/Flowlight/Localization/it.lproj/Localizable.strings index 44f376f9..c23db4c4 100644 --- a/Flowlight/Localization/it.lproj/Localizable.strings +++ b/Flowlight/Localization/it.lproj/Localizable.strings @@ -1166,7 +1166,23 @@ "%lld blocked" = "%lld bloccate"; "%lld exceptions" = "%lld eccezioni"; "Review…" = "Esamina…"; -"Official · Built into Flowlight · pack v%@ · %@" = "Ufficiale · Integrato in Flowlight · pacchetto v%@ · %@"; +"Official pack updates" = "Aggiornamenti dei pacchetti ufficiali"; +"Flowlight checks its fixed HTTPS feed for publisher-signed destination lists. No traffic, pack settings, or device identifier is sent." = "Flowlight controlla il suo feed HTTPS fisso per trovare elenchi di destinazioni firmati dall’editore. Non vengono inviati dati sul traffico, impostazioni dei pacchetti o identificatori del dispositivo."; +"Automatic updates" = "Aggiornamenti automatici"; +"Sequence %lld" = "Sequenza %lld"; +"Last verified %@" = "Ultima verifica %@"; +"Next check %@" = "Prossimo controllo %@"; +"Official source: %@" = "Fonte ufficiale: %@"; +"Publisher: Flowlight · signer %@ · catalog digest %@" = "Editore: Flowlight · firmatario %@ · digest del catalogo %@"; +"Verified and current" = "Verificato e aggiornato"; +"Verified last-known-good is stale" = "L’ultima versione valida verificata è obsoleta"; +"Recovery required" = "Ripristino necessario"; +"Check failed · using last-known-good" = "Controllo non riuscito · viene usata l’ultima versione valida"; +"Check failed" = "Controllo non riuscito"; +"Official · Verified official update · pack v%@ · %@" = "Ufficiale · Aggiornamento ufficiale verificato · pacchetto v%@ · %@"; +"Official · Bundled baseline · pack v%@ · %@" = "Ufficiale · Versione di base inclusa · pacchetto v%@ · %@"; +"Apply failed" = "Impossibile applicare"; +"This pack changed while it was open. Reopen it to review the new content before saving." = "Questo pacchetto è cambiato mentre era aperto. Riaprilo per verificare il nuovo contenuto prima di salvare."; "Applying…" = "Applicazione…"; "Watching only" = "Solo osservazione"; "Blocking" = "Blocco"; @@ -1717,3 +1733,4 @@ "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; "Suggests reviewed actions" = "Suggests reviewed actions"; "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; +"Couldn't delete the captured data: %@" = "Impossibile eliminare i dati acquisiti: %@"; diff --git a/Flowlight/Localization/ja.lproj/Localizable.strings b/Flowlight/Localization/ja.lproj/Localizable.strings index c5610e2d..3a47e31b 100644 --- a/Flowlight/Localization/ja.lproj/Localizable.strings +++ b/Flowlight/Localization/ja.lproj/Localizable.strings @@ -1166,7 +1166,23 @@ "%lld blocked" = "%lld 件をブロック"; "%lld exceptions" = "例外 %lld 件"; "Review…" = "確認…"; -"Official · Built into Flowlight · pack v%@ · %@" = "公式 · Flowlight 内蔵 · パック v%@ · %@"; +"Official pack updates" = "公式パックのアップデート"; +"Flowlight checks its fixed HTTPS feed for publisher-signed destination lists. No traffic, pack settings, or device identifier is sent." = "Flowlight は固定の HTTPS フィードを確認し、発行元が署名した宛先リストを取得します。トラフィック、パック設定、デバイス識別子は送信されません。"; +"Automatic updates" = "自動アップデート"; +"Sequence %lld" = "シーケンス %lld"; +"Last verified %@" = "最終検証:%@"; +"Next check %@" = "次回確認:%@"; +"Official source: %@" = "公式ソース:%@"; +"Publisher: Flowlight · signer %@ · catalog digest %@" = "発行元:Flowlight · 署名者 %@ · カタログダイジェスト %@"; +"Verified and current" = "検証済み・最新"; +"Verified last-known-good is stale" = "検証済みの最終正常版が古くなっています"; +"Recovery required" = "復旧が必要"; +"Check failed · using last-known-good" = "確認に失敗 · 最終正常版を使用中"; +"Check failed" = "確認に失敗"; +"Official · Verified official update · pack v%@ · %@" = "公式 · 検証済みの公式アップデート · パック v%@ · %@"; +"Official · Bundled baseline · pack v%@ · %@" = "公式 · 同梱の基本版 · パック v%@ · %@"; +"Apply failed" = "適用に失敗"; +"This pack changed while it was open. Reopen it to review the new content before saving." = "このパックを開いている間に内容が変更されました。保存する前に開き直して、新しい内容を確認してください。"; "Applying…" = "適用中…"; "Watching only" = "監視のみ"; "Blocking" = "ブロック中"; @@ -1717,3 +1733,4 @@ "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; "Suggests reviewed actions" = "Suggests reviewed actions"; "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; +"Couldn't delete the captured data: %@" = "キャプチャしたデータを削除できませんでした:%@"; diff --git a/Flowlight/Localization/ko.lproj/Localizable.strings b/Flowlight/Localization/ko.lproj/Localizable.strings index 7acffcbb..ae457c6a 100644 --- a/Flowlight/Localization/ko.lproj/Localizable.strings +++ b/Flowlight/Localization/ko.lproj/Localizable.strings @@ -1189,7 +1189,23 @@ "%lld blocked" = "%lld개 차단"; "%lld exceptions" = "예외 %lld개"; "Review…" = "검토…"; -"Official · Built into Flowlight · pack v%@ · %@" = "공식 · Flowlight 내장 · 팩 v%@ · %@"; +"Official pack updates" = "공식 팩 업데이트"; +"Flowlight checks its fixed HTTPS feed for publisher-signed destination lists. No traffic, pack settings, or device identifier is sent." = "Flowlight는 고정된 HTTPS 피드에서 게시자가 서명한 대상 목록을 확인합니다. 트래픽, 팩 설정 또는 기기 식별자는 전송되지 않습니다."; +"Automatic updates" = "자동 업데이트"; +"Sequence %lld" = "시퀀스 %lld"; +"Last verified %@" = "마지막 검증 %@"; +"Next check %@" = "다음 확인 %@"; +"Official source: %@" = "공식 출처: %@"; +"Publisher: Flowlight · signer %@ · catalog digest %@" = "게시자: Flowlight · 서명자 %@ · 카탈로그 다이제스트 %@"; +"Verified and current" = "검증되었으며 최신 상태임"; +"Verified last-known-good is stale" = "검증된 마지막 정상 버전이 오래됨"; +"Recovery required" = "복구 필요"; +"Check failed · using last-known-good" = "확인 실패 · 마지막 정상 버전 사용 중"; +"Check failed" = "확인 실패"; +"Official · Verified official update · pack v%@ · %@" = "공식 · 검증된 공식 업데이트 · 팩 v%@ · %@"; +"Official · Bundled baseline · pack v%@ · %@" = "공식 · 번들 기준 버전 · 팩 v%@ · %@"; +"Apply failed" = "적용 실패"; +"This pack changed while it was open. Reopen it to review the new content before saving." = "이 팩을 열어 둔 동안 내용이 변경되었습니다. 저장하기 전에 다시 열어 새 내용을 검토하세요."; "Applying…" = "적용 중…"; "Watching only" = "관찰만"; "Blocking" = "차단 중"; @@ -1716,3 +1732,4 @@ "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; "Suggests reviewed actions" = "Suggests reviewed actions"; "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; +"Couldn't delete the captured data: %@" = "캡처한 데이터를 삭제할 수 없습니다: %@"; diff --git a/Flowlight/Localization/pt-PT.lproj/Localizable.strings b/Flowlight/Localization/pt-PT.lproj/Localizable.strings index a21b4f9e..6e2dd1ff 100644 --- a/Flowlight/Localization/pt-PT.lproj/Localizable.strings +++ b/Flowlight/Localization/pt-PT.lproj/Localizable.strings @@ -1189,7 +1189,23 @@ "%lld blocked" = "%lld bloqueadas"; "%lld exceptions" = "%lld exceções"; "Review…" = "Rever…"; -"Official · Built into Flowlight · pack v%@ · %@" = "Oficial · Integrado no Flowlight · pacote v%@ · %@"; +"Official pack updates" = "Atualizações dos pacotes oficiais"; +"Flowlight checks its fixed HTTPS feed for publisher-signed destination lists. No traffic, pack settings, or device identifier is sent." = "O Flowlight consulta o respetivo feed HTTPS fixo para obter listas de destinos assinadas pelo editor. Não são enviados dados de tráfego, definições dos pacotes nem identificadores do dispositivo."; +"Automatic updates" = "Atualizações automáticas"; +"Sequence %lld" = "Sequência %lld"; +"Last verified %@" = "Última verificação %@"; +"Next check %@" = "Próxima verificação %@"; +"Official source: %@" = "Fonte oficial: %@"; +"Publisher: Flowlight · signer %@ · catalog digest %@" = "Editor: Flowlight · signatário %@ · síntese do catálogo %@"; +"Verified and current" = "Verificado e atualizado"; +"Verified last-known-good is stale" = "A última versão válida verificada está desatualizada"; +"Recovery required" = "Recuperação necessária"; +"Check failed · using last-known-good" = "A verificação falhou · a usar a última versão válida"; +"Check failed" = "A verificação falhou"; +"Official · Verified official update · pack v%@ · %@" = "Oficial · Atualização oficial verificada · pacote v%@ · %@"; +"Official · Bundled baseline · pack v%@ · %@" = "Oficial · Versão de base incluída · pacote v%@ · %@"; +"Apply failed" = "Falha ao aplicar"; +"This pack changed while it was open. Reopen it to review the new content before saving." = "Este pacote foi alterado enquanto estava aberto. Volte a abri-lo para rever o novo conteúdo antes de guardar."; "Applying…" = "A aplicar…"; "Watching only" = "Só a vigiar"; "Blocking" = "A bloquear"; @@ -1716,3 +1732,4 @@ "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; "Suggests reviewed actions" = "Suggests reviewed actions"; "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; +"Couldn't delete the captured data: %@" = "Não foi possível eliminar os dados capturados: %@"; diff --git a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings index 658170f8..7971eba9 100644 --- a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings +++ b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings @@ -1189,7 +1189,23 @@ "%lld blocked" = "已阻止 %lld 个"; "%lld exceptions" = "%lld 个例外"; "Review…" = "检查…"; -"Official · Built into Flowlight · pack v%@ · %@" = "官方 · Flowlight 内置 · 包 v%@ · %@"; +"Official pack updates" = "官方包更新"; +"Flowlight checks its fixed HTTPS feed for publisher-signed destination lists. No traffic, pack settings, or device identifier is sent." = "Flowlight 会检查其固定的 HTTPS 源,以获取由发布者签名的目标列表。不会发送流量数据、包设置或设备标识符。"; +"Automatic updates" = "自动更新"; +"Sequence %lld" = "序列 %lld"; +"Last verified %@" = "上次验证 %@"; +"Next check %@" = "下次检查 %@"; +"Official source: %@" = "官方来源:%@"; +"Publisher: Flowlight · signer %@ · catalog digest %@" = "发布者:Flowlight · 签名者 %@ · 目录摘要 %@"; +"Verified and current" = "已验证且为最新"; +"Verified last-known-good is stale" = "已验证的最后已知可用版本已过期"; +"Recovery required" = "需要恢复"; +"Check failed · using last-known-good" = "检查失败 · 正在使用最后已知可用版本"; +"Check failed" = "检查失败"; +"Official · Verified official update · pack v%@ · %@" = "官方 · 已验证的官方更新 · 包 v%@ · %@"; +"Official · Bundled baseline · pack v%@ · %@" = "官方 · 内置基准版本 · 包 v%@ · %@"; +"Apply failed" = "应用失败"; +"This pack changed while it was open. Reopen it to review the new content before saving." = "此包在打开期间已发生更改。请重新打开并查看新内容,然后再保存。"; "Applying…" = "正在应用…"; "Watching only" = "仅监视"; "Blocking" = "正在阻止"; @@ -1716,3 +1732,4 @@ "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "待翻译:Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; "Suggests reviewed actions" = "待翻译:Suggests reviewed actions"; "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "待翻译:Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; +"Couldn't delete the captured data: %@" = "无法删除捕获的数据:%@"; diff --git a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings index 15820da2..ec4ca334 100644 --- a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings +++ b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings @@ -1189,7 +1189,23 @@ "%lld blocked" = "已封鎖 %lld 個"; "%lld exceptions" = "%lld 個例外"; "Review…" = "檢視…"; -"Official · Built into Flowlight · pack v%@ · %@" = "官方 · Flowlight 內建 · 套件 v%@ · %@"; +"Official pack updates" = "官方套件更新"; +"Flowlight checks its fixed HTTPS feed for publisher-signed destination lists. No traffic, pack settings, or device identifier is sent." = "Flowlight 會檢查其固定的 HTTPS 來源,以取得由發布者簽署的目的地清單。不會傳送流量資料、套件設定或裝置識別碼。"; +"Automatic updates" = "自動更新"; +"Sequence %lld" = "序號 %lld"; +"Last verified %@" = "上次驗證 %@"; +"Next check %@" = "下次檢查 %@"; +"Official source: %@" = "官方來源:%@"; +"Publisher: Flowlight · signer %@ · catalog digest %@" = "發布者:Flowlight · 簽署者 %@ · 目錄摘要 %@"; +"Verified and current" = "已驗證且為最新"; +"Verified last-known-good is stale" = "已驗證的最後已知正常版本已過期"; +"Recovery required" = "需要復原"; +"Check failed · using last-known-good" = "檢查失敗 · 正在使用最後已知正常版本"; +"Check failed" = "檢查失敗"; +"Official · Verified official update · pack v%@ · %@" = "官方 · 已驗證的官方更新 · 套件 v%@ · %@"; +"Official · Bundled baseline · pack v%@ · %@" = "官方 · 內建基準版本 · 套件 v%@ · %@"; +"Apply failed" = "套用失敗"; +"This pack changed while it was open. Reopen it to review the new content before saving." = "此套件在開啟期間已變更。請重新開啟並檢視新內容,然後再儲存。"; "Applying…" = "正在套用…"; "Watching only" = "僅監看"; "Blocking" = "正在封鎖"; @@ -1716,3 +1732,4 @@ "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; "Suggests reviewed actions" = "Suggests reviewed actions"; "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; +"Couldn't delete the captured data: %@" = "無法刪除擷取的資料:%@"; diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift index cd7a82d9..d50d1735 100644 --- a/Flowlight/Storage/TrafficDatabase.swift +++ b/Flowlight/Storage/TrafficDatabase.swift @@ -486,6 +486,18 @@ final class TrafficDatabase: @unchecked Sendable { -- Managed packs keep only user state here. The verified entry lists remain in the signed app bundle. CREATE TABLE IF NOT EXISTS rule_packs ( pack_id TEXT PRIMARY KEY, configuration TEXT NOT NULL, updated INTEGER NOT NULL); + -- The anti-rollback receipt is deliberately independent from the cached bytes. A corrupt or missing cache + -- must never make a previously accepted sequence disappear and permit the bundled sequence again. + CREATE TABLE IF NOT EXISTS rule_pack_catalog_receipts ( + catalog_id TEXT PRIMARY KEY, highest_sequence INTEGER NOT NULL, + canonical_digest TEXT NOT NULL, signer_key_id TEXT NOT NULL, accepted_at INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS rule_pack_catalog_cache ( + catalog_id TEXT PRIMARY KEY, raw_envelope BLOB NOT NULL, etag TEXT, + last_modified TEXT, verified_at INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS rule_pack_catalog_updates ( + catalog_id TEXT PRIMARY KEY, automatic_refresh INTEGER NOT NULL DEFAULT 1, + last_attempt INTEGER, last_success INTEGER, next_eligible_attempt INTEGER, + failure_count INTEGER NOT NULL DEFAULT 0, last_status TEXT, last_error TEXT); CREATE TABLE IF NOT EXISTS rule_pack_overrides ( id TEXT PRIMARY KEY, override TEXT NOT NULL, updated INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS rule_pack_event_rollups ( @@ -1005,6 +1017,19 @@ final class TrafficDatabase: @unchecked Sendable { } func recordRuleEvents(_ events: [RuleEvent]) throws { + try writeRuleEvents(events) + } + + /// A bounded proxy decision is not carried out until both its spent count and visible explanation commit. Keeping + /// them in one transaction prevents a crash between those writes from reopening a one-shot rule without history. + func recordBoundedRuleDecision(rule: Rule, event: RuleEvent) throws { + try conn.transaction { + try saveRule(rule) + try writeRuleEvents([event]) + } + } + + private func writeRuleEvents(_ events: [RuleEvent]) throws { let sql = """ INSERT INTO rule_events (ts, rule_id, action, engine, bundle_id, app_name, agent, agent_name, host, ip, port, path, method) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) @@ -1053,12 +1078,250 @@ final class TrafficDatabase: @unchecked Sendable { } } + /// Catalog reconciliation is a security boundary. A malformed saved row must roll the transaction back rather + /// than disappearing through the tolerant loader used by older UI reloads. + private func loadRulePackConfigurationsStrictly() throws -> [RulePackConfiguration] { + let rows = try conn.query("SELECT configuration FROM rule_packs ORDER BY pack_id") { $0.text(0) } + return try rows.map { try JSONDecoder().decode(RulePackConfiguration.self, from: Data($0.utf8)) } + } + func saveRulePackConfiguration(_ configuration: RulePackConfiguration) throws { + try writeRulePackConfiguration(configuration, updatedAt: Date()) + } + + /// Compare-and-save for an editor that may have remained open across a catalog refresh. Disabling never needs + /// a receipt; enabling requires both the reviewed pack identity and the durable catalog watermark to still match. + func saveRulePackConfiguration(_ configuration: RulePackConfiguration, + reviewed receipt: RulePackReviewReceipt?) throws { + try conn.transaction { + if configuration.enabled { + guard let receipt, + receipt.packID == configuration.packID, + receipt.version == configuration.consentedVersion, + receipt.contentSHA256 == configuration.consentedContentSHA256, + let durable = try loadRulePackCatalogReceipt(catalogID: RulePackCatalogVerifier.catalogID), + durable.sequence == receipt.catalogSequence, + durable.digest == receipt.catalogDigest else { + throw RulePackCatalogPersistenceError.staleReview + } + } + try writeRulePackConfiguration(configuration, updatedAt: Date()) + } + } + + private func writeRulePackConfiguration(_ configuration: RulePackConfiguration, updatedAt: Date) throws { let json = String(decoding: try JSONEncoder().encode(configuration), as: UTF8.self) try conn.run(""" INSERT INTO rule_packs (pack_id, configuration, updated) VALUES (?,?,?) ON CONFLICT(pack_id) DO UPDATE SET configuration = excluded.configuration, updated = excluded.updated - """, [.text(configuration.packID), .text(json), .int(Int64(Date().timeIntervalSince1970))]) + """, [.text(configuration.packID), .text(json), .int(Int64(updatedAt.timeIntervalSince1970))]) + } + + func loadRulePackCatalogBootstrap(catalogID: String) throws -> RulePackCatalogAcceptance { + RulePackCatalogAcceptance( + state: try loadRulePackCatalogState(catalogID: catalogID), + configurations: try loadRulePackConfigurationsStrictly(), + overrides: try loadRulePackOverrides(), + events: try rulePackEvents(limit: 300), + deliveryStats: try rulePackDeliveryStats() + ) + } + + func loadRulePackCatalogState(catalogID: String) throws -> RulePackCatalogState { + guard !catalogID.isEmpty else { throw RulePackCatalogPersistenceError.invalidCatalogID } + let receipt = try loadRulePackCatalogReceipt(catalogID: catalogID) + let cache = try conn.query(""" + SELECT raw_envelope, etag, last_modified, verified_at + FROM rule_pack_catalog_cache WHERE catalog_id = ? + """, [.text(catalogID)]) { row in + RulePackCatalogCache( + catalogID: catalogID, + rawEnvelope: row.blob(0), + etag: row.isNull(1) ? nil : row.text(1), + lastModified: row.isNull(2) ? nil : row.text(2), + verifiedAt: Date(timeIntervalSince1970: TimeInterval(row.int(3))) + ) + }.first + let update = try conn.query(""" + SELECT automatic_refresh, last_attempt, last_success, next_eligible_attempt, + failure_count, last_status, last_error + FROM rule_pack_catalog_updates WHERE catalog_id = ? + """, [.text(catalogID)]) { row in + RulePackCatalogUpdateState( + catalogID: catalogID, + automaticRefresh: row.int(0) != 0, + lastAttempt: row.isNull(1) ? nil : Date(timeIntervalSince1970: TimeInterval(row.int(1))), + lastSuccess: row.isNull(2) ? nil : Date(timeIntervalSince1970: TimeInterval(row.int(2))), + nextEligibleAttempt: row.isNull(3) ? nil : Date(timeIntervalSince1970: TimeInterval(row.int(3))), + failureCount: Int(row.int(4)), + lastStatus: row.isNull(5) ? nil : row.text(5), + lastError: row.isNull(6) ? nil : row.text(6) + ) + }.first ?? RulePackCatalogUpdateState(catalogID: catalogID) + return RulePackCatalogState(receipt: receipt, cache: cache, update: update) + } + + /// Atomically rechecks anti-rollback state, saves the exact signed bytes and validators, invalidates stale + /// consent, and resets refresh backoff. Callers may publish the returned definitions/configurations only after + /// this method returns successfully. + func acceptVerifiedRulePackCatalog(_ catalog: VerifiedRulePackCatalog, + catalogID: String = RulePackCatalogVerifier.catalogID, + etag: String? = nil, + lastModified: String? = nil, + checkedAt: Date, + nextEligibleAt: Date) throws -> RulePackCatalogAcceptance { + guard catalogID == RulePackCatalogVerifier.catalogID else { + throw RulePackCatalogPersistenceError.invalidCatalogID + } + guard catalog.sequence > 0, !catalog.digest.isEmpty, !catalog.signerKeyID.isEmpty, + !catalog.rawEnvelope.isEmpty else { + throw RulePackCatalogPersistenceError.invalidReceipt + } + var packIDs = Set() + guard catalog.definitions.allSatisfy({ packIDs.insert($0.id).inserted }) else { + throw RulePackValidationError.duplicatePack(catalog.definitions.first?.id ?? "") + } + + var reconciled: [RulePackConfiguration] = [] + try conn.transaction { + if let prior = try loadRulePackCatalogReceipt(catalogID: catalogID) { + if catalog.sequence < prior.sequence { throw RulePackCatalogPersistenceError.rollback } + if catalog.sequence == prior.sequence, catalog.digest != prior.digest { + throw RulePackCatalogPersistenceError.equivocation + } + } + + let acceptedAt = Int64(checkedAt.timeIntervalSince1970) + try conn.run(""" + INSERT INTO rule_pack_catalog_receipts + (catalog_id, highest_sequence, canonical_digest, signer_key_id, accepted_at) + VALUES (?,?,?,?,?) + ON CONFLICT(catalog_id) DO UPDATE SET + highest_sequence = excluded.highest_sequence, + canonical_digest = excluded.canonical_digest, + signer_key_id = excluded.signer_key_id, + accepted_at = excluded.accepted_at + """, [.text(catalogID), .int(catalog.sequence), .text(catalog.digest), + .text(catalog.signerKeyID), .int(acceptedAt)]) + try conn.run(""" + INSERT INTO rule_pack_catalog_cache + (catalog_id, raw_envelope, etag, last_modified, verified_at) + VALUES (?,?,?,?,?) + ON CONFLICT(catalog_id) DO UPDATE SET + raw_envelope = excluded.raw_envelope, etag = excluded.etag, + last_modified = excluded.last_modified, verified_at = excluded.verified_at + """, [.text(catalogID), .blob(catalog.rawEnvelope), etag.map(SQLValue.text) ?? .null, + lastModified.map(SQLValue.text) ?? .null, .int(acceptedAt)]) + try conn.run(""" + INSERT INTO rule_pack_catalog_updates + (catalog_id, automatic_refresh, last_attempt, last_success, next_eligible_attempt, + failure_count, last_status, last_error) + VALUES (?,1,?,?,?,0,?,NULL) + ON CONFLICT(catalog_id) DO UPDATE SET + last_attempt = excluded.last_attempt, last_success = excluded.last_success, + next_eligible_attempt = excluded.next_eligible_attempt, + failure_count = 0, last_status = excluded.last_status, last_error = NULL + """, [.text(catalogID), .int(acceptedAt), .int(acceptedAt), + .int(Int64(nextEligibleAt.timeIntervalSince1970)), .text("accepted")]) + + let definitionsByID = Dictionary(uniqueKeysWithValues: catalog.definitions.map { ($0.id, $0) }) + let saved = try loadRulePackConfigurationsStrictly() + for var configuration in saved { + if let definition = definitionsByID[configuration.packID], configuration.enabled, + !configuration.isConsented(to: definition) { + configuration.enabled = false + try writeRulePackConfiguration(configuration, updatedAt: checkedAt) + } + reconciled.append(configuration) + } + } + + let state = try loadRulePackCatalogState(catalogID: catalogID) + return RulePackCatalogAcceptance( + state: state, + configurations: reconciled.sorted { $0.packID < $1.packID }, + overrides: try loadRulePackOverrides(), + events: try rulePackEvents(limit: 300), + deliveryStats: try rulePackDeliveryStats() + ) + } + + /// A 304 is meaningful only after the caller has reverified the cached bytes. The expected receipt makes that + /// re-verification a compare-and-set instead of allowing stale in-memory state to mark a newer row successful. + func recordRulePackCatalogNotModified(catalogID: String, + expectedSequence: Int64, + expectedDigest: String, + checkedAt: Date, + nextEligibleAt: Date) throws { + try conn.transaction { + guard let receipt = try loadRulePackCatalogReceipt(catalogID: catalogID), + receipt.sequence == expectedSequence, + receipt.digest == expectedDigest, + try conn.query("SELECT 1 FROM rule_pack_catalog_cache WHERE catalog_id = ?", + [.text(catalogID)], map: { $0.int(0) }).first == 1 else { + throw RulePackCatalogPersistenceError.invalidReceipt + } + let checked = Int64(checkedAt.timeIntervalSince1970) + try conn.run(""" + INSERT INTO rule_pack_catalog_updates + (catalog_id, automatic_refresh, last_attempt, last_success, next_eligible_attempt, + failure_count, last_status, last_error) + VALUES (?,1,?,?,?,0,?,NULL) + ON CONFLICT(catalog_id) DO UPDATE SET + last_attempt = excluded.last_attempt, last_success = excluded.last_success, + next_eligible_attempt = excluded.next_eligible_attempt, + failure_count = 0, last_status = excluded.last_status, last_error = NULL + """, [.text(catalogID), .int(checked), .int(checked), + .int(Int64(nextEligibleAt.timeIntervalSince1970)), .text("not-modified")]) + } + } + + func setRulePackCatalogAutomaticRefresh(catalogID: String, enabled: Bool) throws { + guard !catalogID.isEmpty else { throw RulePackCatalogPersistenceError.invalidCatalogID } + try conn.run(""" + INSERT INTO rule_pack_catalog_updates (catalog_id, automatic_refresh, failure_count) + VALUES (?,?,0) + ON CONFLICT(catalog_id) DO UPDATE SET automatic_refresh = excluded.automatic_refresh + """, [.text(catalogID), .int(enabled ? 1 : 0)]) + } + + func recordRulePackCatalogFailure(catalogID: String, + attemptedAt: Date, + failureCount: Int, + nextEligibleAt: Date, + status: String, + error: String) throws { + guard !catalogID.isEmpty, failureCount > 0 else { + throw RulePackCatalogPersistenceError.invalidReceipt + } + try conn.run(""" + INSERT INTO rule_pack_catalog_updates + (catalog_id, automatic_refresh, last_attempt, last_success, next_eligible_attempt, + failure_count, last_status, last_error) + VALUES (?,1,?,NULL,?,?,?,?) + ON CONFLICT(catalog_id) DO UPDATE SET + last_attempt = excluded.last_attempt, + next_eligible_attempt = excluded.next_eligible_attempt, + failure_count = excluded.failure_count, + last_status = excluded.last_status, last_error = excluded.last_error + """, [.text(catalogID), .int(Int64(attemptedAt.timeIntervalSince1970)), + .int(Int64(nextEligibleAt.timeIntervalSince1970)), .int(Int64(failureCount)), + .text(status), .text(error)]) + } + + private func loadRulePackCatalogReceipt(catalogID: String) throws -> RulePackCatalogReceipt? { + try conn.query(""" + SELECT highest_sequence, canonical_digest, signer_key_id, accepted_at + FROM rule_pack_catalog_receipts WHERE catalog_id = ? + """, [.text(catalogID)]) { row in + RulePackCatalogReceipt( + catalogID: catalogID, + sequence: row.int(0), + digest: row.text(1), + signerKeyID: row.text(2), + acceptedAt: Date(timeIntervalSince1970: TimeInterval(row.int(3))) + ) + }.first } func loadRulePackOverrides() throws -> [RulePackOverride] { @@ -1094,21 +1357,23 @@ final class TrafficDatabase: @unchecked Sendable { func recordRulePackEventBatch(_ batch: RulePackEventBatch) throws -> Bool { var inserted = false try conn.transaction { - let session = batch.deliverySession.uuidString - let watermark = try conn.query( - "SELECT sequence FROM rule_pack_event_watermarks WHERE delivery_session = ?", - [.text(session)], map: { $0.int(0) } - ).first ?? 0 - guard batch.sequence > watermark else { return } - try conn.run(""" - INSERT INTO rule_pack_event_watermarks - (delivery_session, sequence, received, dropped_hits, dropped_keys) VALUES (?,?,?,?,?) - ON CONFLICT(delivery_session) DO UPDATE SET - sequence = excluded.sequence, received = excluded.received, - dropped_hits = dropped_hits + excluded.dropped_hits, - dropped_keys = dropped_keys + excluded.dropped_keys - """, [.text(session), .int(batch.sequence), .int(Int64(Date().timeIntervalSince1970)), - .int(batch.droppedHits), .int(batch.droppedKeys)]) + if batch.requiresDurableWatermark { + let session = batch.deliverySession.uuidString + let watermark = try conn.query( + "SELECT sequence FROM rule_pack_event_watermarks WHERE delivery_session = ?", + [.text(session)], map: { $0.int(0) } + ).first ?? 0 + guard batch.sequence > watermark else { return } + try conn.run(""" + INSERT INTO rule_pack_event_watermarks + (delivery_session, sequence, received, dropped_hits, dropped_keys) VALUES (?,?,?,?,?) + ON CONFLICT(delivery_session) DO UPDATE SET + sequence = excluded.sequence, received = excluded.received, + dropped_hits = dropped_hits + excluded.dropped_hits, + dropped_keys = dropped_keys + excluded.dropped_keys + """, [.text(session), .int(batch.sequence), .int(Int64(Date().timeIntervalSince1970)), + .int(batch.droppedHits), .int(batch.droppedKeys)]) + } try recordRulePackEvents(batch.events, inTransaction: true) inserted = true } @@ -1790,7 +2055,7 @@ final class TrafficDatabase: @unchecked Sendable { func clearAll() throws { try conn.transaction { - for t in Self.tables + ["rollup_state", "seen_destinations", "seen_ports", "apps", "baselines", "alerts", "http_exchanges", "local_risk_assessments", "plugin_findings", "rule_pack_event_rollups"] { + for t in Self.tables + ["rollup_state", "seen_destinations", "seen_ports", "apps", "baselines", "alerts", "http_exchanges", "local_risk_assessments", "plugin_findings", "rule_events", "rule_pack_event_rollups"] { try conn.run("DELETE FROM \(t)") } // Keep event batch IDs so an acknowledgment lost before "Delete everything" cannot resurrect activity. @@ -1807,4 +2072,12 @@ final class TrafficDatabase: @unchecked Sendable { do { try body(self) } catch { appLog.error("db error: \(String(describing: error), privacy: .public)") } } } + + /// Serialized work whose success or failure controls a caller decision. Unlike `async`, this never turns a + /// persistence failure into a log-only event, and callers must not invoke `sync` from inside its body. + func asyncResult(_ body: @escaping @Sendable (TrafficDatabase) throws -> T) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + queue.async { continuation.resume(with: Result { try body(self) }) } + } + } } diff --git a/Flowlight/UI/RulePackEditorDraft.swift b/Flowlight/UI/RulePackEditorDraft.swift index ca31f83c..0aee2fee 100644 --- a/Flowlight/UI/RulePackEditorDraft.swift +++ b/Flowlight/UI/RulePackEditorDraft.swift @@ -15,16 +15,22 @@ struct RulePackEditorDraft { } enum SaveAction: Equatable { - case commit(RulePackConfiguration) + case commit(RulePackConfiguration, RulePackReviewReceipt?) case confirmBroadening } let originalConfiguration: RulePackConfiguration + let reviewedDefinition: RulePackDefinition? + let reviewReceipt: RulePackReviewReceipt? var configuration: RulePackConfiguration var typedApp = "" - init(saved: RulePackConfiguration, intent: Intent) { + init(saved: RulePackConfiguration, intent: Intent, + reviewedDefinition: RulePackDefinition? = nil, + reviewReceipt: RulePackReviewReceipt? = nil) { originalConfiguration = saved + self.reviewedDefinition = reviewedDefinition + self.reviewReceipt = reviewReceipt configuration = saved if intent == .activate { configuration.enabled = true } } @@ -62,6 +68,6 @@ struct RulePackEditorDraft { configuration.scope == .allApps { return .confirmBroadening } - return .commit(configuration) + return .commit(configuration, reviewReceipt) } } diff --git a/Flowlight/UI/RulePacksView.swift b/Flowlight/UI/RulePacksView.swift index 23d01f3e..b0e5602a 100644 --- a/Flowlight/UI/RulePacksView.swift +++ b/Flowlight/UI/RulePacksView.swift @@ -4,12 +4,13 @@ struct RulePacksView: View { @EnvironmentObject var monitor: TrafficMonitor var body: some View { - RulePacksContent(packs: monitor.rulePacks) + RulePacksContent(packs: monitor.rulePacks, catalog: monitor.rulePackCatalog) } } private struct RulePackEditorRequest: Identifiable { let pack: RulePackDefinition + let receipt: RulePackReviewReceipt? let intent: RulePackEditorDraft.Intent var id: String { pack.id } } @@ -25,6 +26,7 @@ struct RulePackSaveTracker { private struct RulePacksContent: View { @EnvironmentObject var monitor: TrafficMonitor @ObservedObject var packs: RulePackStore + @ObservedObject var catalog: RulePackCatalogUpdater @State private var editor: RulePackEditorRequest? @State private var saves = RulePackSaveTracker() @State private var saveErrors: [String: String] = [:] @@ -33,6 +35,12 @@ private struct RulePacksContent: View { ScrollView { LazyVStack(alignment: .leading, spacing: 10) { explanation + officialUpdates + if let failure = packs.failureMessage { + Label(failure, systemImage: "exclamationmark.triangle") + .font(.caption).foregroundStyle(FL.warning) + .padding(10).background(FL.warning.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) + } if packs.deliveryStats.droppedHits > 0 { Label(L("%lld managed-pack decisions were omitted from activity because the extension's bounded queue was full.", packs.deliveryStats.droppedHits), systemImage: "exclamationmark.triangle") @@ -46,7 +54,8 @@ private struct RulePacksContent: View { .padding(Measure.gutter) } .sheet(item: $editor) { request in - RulePackEditor(pack: request.pack, saved: packs.configuration(for: request.pack.id), + RulePackEditor(pack: request.pack, receipt: request.receipt, + saved: packs.configuration(for: request.pack.id), intent: request.intent, packs: packs, rules: monitor.rules) } } @@ -60,6 +69,85 @@ private struct RulePacksContent: View { .padding(.bottom, 4) } + private var officialUpdates: some View { + VStack(alignment: .leading, spacing: 9) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(L("Official pack updates")).font(.body.weight(.semibold)) + Text(L("Flowlight checks its fixed HTTPS feed for publisher-signed destination lists. No traffic, pack settings, or device identifier is sent.")) + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 12) + Toggle(L("Automatic updates"), isOn: Binding( + get: { catalog.automaticRefresh }, + set: { enabled in Task { await catalog.setAutomaticRefresh(enabled) } } + )) + .toggleStyle(.switch).controlSize(.small) + } + HStack(spacing: 8) { + pill(catalogStatusText, tint: catalogStatusTint) + if let sequence = catalog.receipt?.sequence { pill(L("Sequence %lld", sequence)) } + Spacer() + Button(L("Check Now")) { Task { await catalog.checkNow() } } + .disabled(catalog.status == .checking) + } + if let detail = catalogStatusDetail { + Label(detail, systemImage: "exclamationmark.triangle") + .font(.caption).foregroundStyle(FL.warning) + .fixedSize(horizontal: false, vertical: true) + } + HStack(spacing: 10) { + if let success = catalog.catalogState.update.lastSuccess { + Text(L("Last verified %@", success.formatted(.relative(presentation: .named)))) + } + if catalog.automaticRefresh, + let next = catalog.catalogState.update.nextEligibleAttempt { + Text(L("Next check %@", next.formatted(.relative(presentation: .named)))) + } + } + .font(.caption2).foregroundStyle(.tertiary) + Text(L("Official source: %@", RulePackCatalogVerifier.sourceURL)) + .font(.caption2.monospaced()).foregroundStyle(.tertiary) + .textSelection(.enabled) + if let receipt = catalog.receipt { + Text(L("Publisher: Flowlight · signer %@ · catalog digest %@", + receipt.signerKeyID, String(receipt.digest.prefix(12)))) + .font(.caption2.monospaced()).foregroundStyle(.tertiary) + .textSelection(.enabled) + } + } + .padding(14) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 11)) + } + + private var catalogStatusText: String { + switch catalog.status { + case .idle: return L("Not checked yet") + case .checking: return L("Checking…") + case .current: return L("Verified and current") + case .stale: return L("Verified last-known-good is stale") + case .recovery: return L("Recovery required") + case .failed(_, let usingLastKnownGood): + return usingLastKnownGood ? L("Check failed · using last-known-good") : L("Check failed") + } + } + + private var catalogStatusTint: Color? { + switch catalog.status { + case .current: return FL.good + case .checking, .idle: return nil + case .stale, .recovery, .failed: return FL.warning + } + } + + private var catalogStatusDetail: String? { + switch catalog.status { + case .recovery(let message), .failed(let message, _): return message + case .idle, .checking, .current, .stale: return nil + } + } + private func card(_ pack: RulePackDefinition) -> some View { let configuration = packs.configuration(for: pack.id) let events = packs.events(for: pack.id) @@ -79,7 +167,8 @@ private struct RulePacksContent: View { Toggle("", isOn: Binding(get: { configuration.enabled }, set: { enabled in if enabled { saveErrors[pack.id] = nil - editor = RulePackEditorRequest(pack: pack, intent: .activate) + editor = RulePackEditorRequest(pack: pack, receipt: packs.reviewReceipt(for: pack), + intent: .activate) } else { var changed = configuration changed.enabled = false @@ -93,7 +182,7 @@ private struct RulePacksContent: View { })).toggleStyle(.switch).controlSize(.small).labelsHidden().disabled(isSaving) } HStack(spacing: 8) { - pill(status(configuration), tint: statusTint(configuration)) + pill(status(configuration, definition: pack), tint: statusTint(configuration, definition: pack)) pill(configuration.scope == .allApps ? L("Every app") : L("%lld selected apps", configuration.apps.count)) pill(L("%lld destinations", pack.entries.count)) if blocks > 0 { pill(L("%lld blocked", blocks), tint: FL.critical) } @@ -103,7 +192,8 @@ private struct RulePacksContent: View { Spacer() Button(L("Review…")) { saveErrors[pack.id] = nil - editor = RulePackEditorRequest(pack: pack, intent: .review) + editor = RulePackEditorRequest(pack: pack, receipt: packs.reviewReceipt(for: pack), + intent: .review) }.disabled(isSaving) } if let saveError = saveErrors[pack.id] { @@ -112,36 +202,35 @@ private struct RulePacksContent: View { } Text(L(pack.manifest.caveatKey)).font(.caption).foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) - Text(L("Official · Built into Flowlight · pack v%@ · %@", pack.manifest.version, pack.manifest.license)) + Text(catalog.isDownloadedUpdate(pack) + ? L("Official · Verified official update · pack v%@ · %@", pack.manifest.version, pack.manifest.license) + : L("Official · Bundled baseline · pack v%@ · %@", pack.manifest.version, pack.manifest.license)) .font(.caption2).foregroundStyle(.tertiary) } .padding(14) .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 11)) } - private func status(_ configuration: RulePackConfiguration) -> String { + private func status(_ configuration: RulePackConfiguration, + definition: RulePackDefinition) -> String { + if packs.needsReview(definition) { return L("Needs review") } if !configuration.enabled { - switch packs.status { - case .applying: return L("Applying…") - case .failed: return L("Needs review") - default: return L("Off") - } + return packs.status == .applying ? L("Applying…") : L("Off") } switch packs.status { case .off, .ready, .applying: return monitor.canBlock ? L("Applying…") : L("Watching only") case .enforcing: return monitor.canBlock ? L("Blocking") : L("Watching only") case .watchingOnly: return L("Watching only") case .incompatible: return L("Needs extension update") - case .failed: return L("Needs review") + case .failed: return L("Apply failed") } } - private func statusTint(_ configuration: RulePackConfiguration) -> Color? { + private func statusTint(_ configuration: RulePackConfiguration, + definition: RulePackDefinition) -> Color? { + if packs.needsReview(definition) { return FL.warning } if !configuration.enabled { - switch packs.status { - case .applying, .failed: return FL.warning - default: return nil - } + return packs.status == .applying ? FL.warning : nil } return monitor.canBlock && packs.status == .enforcing ? FL.critical : FL.warning } @@ -164,6 +253,7 @@ private struct RulePacksContent: View { private struct RulePackEditor: View { let pack: RulePackDefinition + let receipt: RulePackReviewReceipt? @EnvironmentObject var monitor: TrafficMonitor @Environment(\.dismiss) private var dismiss @ObservedObject var packs: RulePackStore @@ -179,12 +269,14 @@ private struct RulePackEditor: View { @State private var confirmBroadening = false @State private var saving = false - init(pack: RulePackDefinition, saved: RulePackConfiguration, + init(pack: RulePackDefinition, receipt: RulePackReviewReceipt?, saved: RulePackConfiguration, intent: RulePackEditorDraft.Intent, packs: RulePackStore, rules: RuleStore) { self.pack = pack + self.receipt = receipt self.packs = packs self.rules = rules - _draft = State(initialValue: RulePackEditorDraft(saved: saved, intent: intent)) + _draft = State(initialValue: RulePackEditorDraft(saved: saved, intent: intent, + reviewedDefinition: pack, reviewReceipt: receipt)) } var body: some View { @@ -229,7 +321,9 @@ private struct RulePackEditor: View { .onChange(of: monitor.dataVersion) { _, _ in invalidatePreview() } .onAppear { loadApps() } .confirmationDialog(L("Apply this pack to every app?"), isPresented: $confirmBroadening) { - Button(L("Apply to every app"), role: .destructive) { commit(draft.configuration) } + Button(L("Apply to every app"), role: .destructive) { + commit(draft.configuration, receipt: draft.reviewReceipt) + } Button(L("Cancel"), role: .cancel) {} } message: { Text(L("This broadens blocking from selected apps to every app on this Mac. Essential system traffic remains allowed.")) @@ -319,7 +413,7 @@ private struct RulePackEditor: View { private var destinations: some View { DisclosureGroup(L("%lld destinations", pack.entries.count)) { - VStack(alignment: .leading, spacing: 3) { + LazyVStack(alignment: .leading, spacing: 3) { ForEach(pack.entries) { entry in Text(entry.host).font(.caption.monospaced()).textSelection(.enabled) } @@ -379,22 +473,26 @@ private struct RulePackEditor: View { private func save() { saveError = nil switch draft.requestSave() { - case .commit(let configuration): commit(configuration) + case .commit(let configuration, let receipt): commit(configuration, receipt: receipt) case .confirmBroadening: confirmBroadening = true case nil: break } } - private func commit(_ configuration: RulePackConfiguration? = nil) { + private func commit(_ configuration: RulePackConfiguration? = nil, + receipt: RulePackReviewReceipt? = nil) { saving = true saveError = nil let value = configuration ?? draft.configuration - packs.save(value) { success in + packs.save(value, reviewedDefinition: pack, receipt: receipt) { result in saving = false - if success { + switch result { + case .saved: dismiss() - } else { - saveError = packs.failureMessage ?? L("Failed: %@", L("Save")) + case .staleReview: + saveError = L("This pack changed while it was open. Reopen it to review the new content before saving.") + case .failed(let message): + saveError = message } } } diff --git a/FlowlightExtension/BlockEnforcer.swift b/FlowlightExtension/BlockEnforcer.swift index 282bc176..4e43a221 100644 --- a/FlowlightExtension/BlockEnforcer.swift +++ b/FlowlightExtension/BlockEnforcer.swift @@ -62,10 +62,29 @@ final class BlockEnforcer: @unchecked Sendable { func apply(_ set: RuleSet) -> Int { lock.lock(); defer { lock.unlock() } ruleSet = set - ruleHits = Dictionary(set.rules.map { ($0.id, $0.hits) }, uniquingKeysWith: { a, _ in a }) + ruleHits = Dictionary(set.rules.map { rule in + let published = rule.hits + let runtime = ruleHits[rule.id] ?? published + return (rule.id, set.resetHitCounts.contains(rule.id) ? published : max(published, runtime)) + }, uniquingKeysWith: { a, _ in a }) return set.rules.filter(\.enabled).count } + /// Atomically spends one bounded rule for a connection the local proxy is about to decide. Unbounded rules need + /// no claim. Nil means the app and extension no longer share this rule generation and the proxy must fail open. + func claimRule(_ claim: RuleClaim) -> Bool? { + lock.lock(); defer { lock.unlock() } + guard ruleSet.accepts(claim), + let rule = ruleSet.rules.first(where: { $0.id == claim.ruleID }), + rule.maxHits > 0 else { + return nil + } + let hits = ruleHits[claim.ruleID] ?? rule.hits + guard hits < rule.maxHits else { return false } + ruleHits[claim.ruleID] = hits + 1 + return true + } + func apply(_ snapshot: RulePackSnapshot) throws -> RulePackApplyResult { let matcher = try RulePackMatcher(snapshot: snapshot) lock.lock() @@ -176,25 +195,35 @@ final class BlockEnforcer: @unchecked Sendable { /// is away, for the same reason an allowlist isn't: a refusal nobody can see, and nobody can undo, is worse /// than the connection it stopped. private func ruleDecision(_ facts: FlowFacts, process: ProcessInfoRecord, agent: Agent?) -> RuleDecision { - lock.lock() - let set = ruleSet - let hits = ruleHits - lock.unlock() // The pause stands everything down, allowlists included: it is the one global escape hatch, and an escape // hatch that only half works is worse than none. `.undecided` rather than `.allow`, so the flow is judged // again when the pause ends rather than being waved through for its whole life. - if let until = set.pausedUntil, Date() < until { return RuleDecision(verdict: .undecided, rule: nil) } - guard !set.rules.isEmpty, inForce() else { return RuleDecision(verdict: .allow, rule: nil) } + guard inForce() else { return RuleDecision(verdict: .allow, rule: nil) } if BlockRules.isExempt(facts) { return RuleDecision(verdict: .allow, rule: nil) } + lock.lock() + let set = ruleSet + if let until = set.pausedUntil, Date() < until { + lock.unlock() + return RuleDecision(verdict: .undecided, rule: nil) + } + guard !set.rules.isEmpty else { + lock.unlock() + return RuleDecision(verdict: .allow, rule: nil) + } let rules = set.rules.map { rule -> Rule in var copy = rule - copy.hits = hits[rule.id] ?? rule.hits + copy.hits = ruleHits[rule.id] ?? rule.hits return copy } let decision = RuleBook.decide(facts, rules: rules, session: set.session, pausedUntil: set.pausedUntil) - guard let rule = decision.rule else { return decision } - lock.lock(); ruleHits[rule.id] = (ruleHits[rule.id] ?? 0) + 1; lock.unlock() - let event = RuleEvent(at: Int64(Date().timeIntervalSince1970), ruleID: rule.id, action: rule.action, + guard let rule = decision.rule else { + lock.unlock() + return decision + } + ruleHits[rule.id] = (ruleHits[rule.id] ?? 0) + 1 + lock.unlock() + let event = RuleEvent(at: Int64(Date().timeIntervalSince1970), ruleID: rule.id, + ruleSession: set.session, ruleGeneration: set.generation, action: rule.action, engine: .flow, agentKey: facts.agentKey, agentName: agent?.name ?? process.name, appName: process.name, bundleID: process.bundleID, host: facts.host, ip: facts.ip, port: facts.port) diff --git a/FlowlightExtension/IPCServer.swift b/FlowlightExtension/IPCServer.swift index acf6b54d..5538aa60 100644 --- a/FlowlightExtension/IPCServer.swift +++ b/FlowlightExtension/IPCServer.swift @@ -127,6 +127,7 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u self.blocksInFlight = false self.decisionsInFlight = false self.packEventsInFlight = false + self.packTransfer.reset() // A reconnect begins fail-open. The app may have disabled or narrowed any of these while disconnected; // stale state must not become active during the policy/rule/pack synchronization that follows register. _ = BlockEnforcer.shared.apply([]) @@ -137,30 +138,67 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u self.flushBlocksLocked() self.flushDecisionsLocked() self.flushPackEventsLocked() + // Registration is the synchronization barrier: the app may publish rules only after this reset is done. + reply(true, Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "") } - reply(true, Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "") } func setEnforcement(payload: Data, withReply reply: @escaping (Int) -> Void) { - let policies = payload.isEmpty ? [] : (BlockCoding.decode([AgentPolicy].self, from: payload) ?? []) - let count = BlockEnforcer.shared.apply(policies) - extensionLog.info("Enforcing \(count, privacy: .public) allowlists") - reply(count) + guard let connection = NSXPCConnection.current() else { reply(-1); return } + queue.async { + guard self.client === connection else { reply(-1); return } + let policies = payload.isEmpty ? [] : (BlockCoding.decode([AgentPolicy].self, from: payload) ?? []) + let count = BlockEnforcer.shared.apply(policies) + // Rules are synchronized first. Once both ordinary policy sources are installed, enforcement can safely + // run; managed packs remain independently cleared until their own commit or clear succeeds. + BlockEnforcer.shared.appConnectionChanged(connected: true) + extensionLog.info("Enforcing \(count, privacy: .public) allowlists") + reply(count) + } } func setRules(payload: Data, withReply reply: @escaping (Int) -> Void) { - let set = payload.isEmpty ? RuleSet() : (BlockCoding.decode(RuleSet.self, from: payload) ?? RuleSet()) - let count = BlockEnforcer.shared.apply(set) - extensionLog.info("Carrying out \(count, privacy: .public) rules") - reply(count) + guard let connection = NSXPCConnection.current() else { reply(-1); return } + queue.async { + guard self.client === connection else { reply(-1); return } + let set = payload.isEmpty ? RuleSet() : (BlockCoding.decode(RuleSet.self, from: payload) ?? RuleSet()) + let count = BlockEnforcer.shared.apply(set) + extensionLog.info("Carrying out \(count, privacy: .public) rules") + reply(count) + } + } + + func claimRule(payload: Data, withReply reply: @escaping (Int) -> Void) { + guard let connection = NSXPCConnection.current() else { reply(-1); return } + queue.async { + guard self.client === connection, + let claim = BlockCoding.decode(RuleClaim.self, from: payload), + let claimed = BlockEnforcer.shared.claimRule(claim) else { + reply(-1) + return + } + reply(claimed ? 1 : 0) + } } func rulePackHello(appProtocol: Int, withReply reply: @escaping (Data) -> Void) { - reply(BlockCoding.encode(BlockEnforcer.shared.packHandshake())) + guard let connection = NSXPCConnection.current() else { reply(Data()); return } + queue.async { + guard self.client === connection else { reply(Data()); return } + reply(BlockCoding.encode(BlockEnforcer.shared.packHandshake())) + } } func beginRulePack(payload: Data, withReply reply: @escaping (Data) -> Void) { + guard let connection = NSXPCConnection.current() else { + reply(BlockCoding.encode(RulePackApplyResult(status: .invalid, detail: "No registered app connection"))) + return + } queue.async { + guard self.client === connection else { + reply(BlockCoding.encode(RulePackApplyResult(status: .invalid, detail: "Superseded app connection"))) + return + } guard let header = BlockCoding.decode(RulePackTransferHeader.self, from: payload) else { reply(BlockCoding.encode(RulePackApplyResult(status: .invalid, detail: "Unreadable pack transfer"))) return @@ -171,11 +209,23 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u } func appendRulePack(generation: String, index: Int, payload: Data, withReply reply: @escaping (Bool) -> Void) { - queue.async { reply(self.packTransfer.append(generation: generation, index: index, payload: payload)) } + guard let connection = NSXPCConnection.current() else { reply(false); return } + queue.async { + guard self.client === connection else { reply(false); return } + reply(self.packTransfer.append(generation: generation, index: index, payload: payload)) + } } func commitRulePack(generation: String, withReply reply: @escaping (Data) -> Void) { + guard let connection = NSXPCConnection.current() else { + reply(BlockCoding.encode(RulePackApplyResult(status: .invalid, detail: "No registered app connection"))) + return + } queue.async { + guard self.client === connection else { + reply(BlockCoding.encode(RulePackApplyResult(status: .invalid, detail: "Superseded app connection"))) + return + } switch self.packTransfer.commit(generation: generation, active: BlockEnforcer.shared.packHandshake()) { case .rejected(let result): reply(BlockCoding.encode(result)) @@ -198,7 +248,15 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u } func clearRulePacks(payload: Data, withReply reply: @escaping (Data) -> Void) { + guard let connection = NSXPCConnection.current() else { + reply(BlockCoding.encode(RulePackApplyResult(status: .invalid, detail: "No registered app connection"))) + return + } queue.async { + guard self.client === connection else { + reply(BlockCoding.encode(RulePackApplyResult(status: .invalid, detail: "Superseded app connection"))) + return + } guard let request = BlockCoding.decode(RulePackClearRequest.self, from: payload) else { reply(BlockCoding.encode(RulePackApplyResult(status: .invalid, detail: "Unreadable clear request"))) return @@ -290,6 +348,7 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u } guard let batch = packEventBatch else { return } let capturedClient = client + let capturedClientID = ObjectIdentifier(capturedClient) let proxy = capturedClient.remoteObjectProxyWithErrorHandler { [weak self, weak capturedClient] error in extensionLog.error("rulePackEvents() failed: \(error.localizedDescription, privacy: .public)") self?.queue.async { @@ -303,9 +362,10 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u return } packEventsInFlight = true - proxy.rulePackEvents(payload: BlockCoding.encode(batch)) { [weak self, weak capturedClient] accepted in + proxy.rulePackEvents(payload: BlockCoding.encode(batch)) { [weak self] accepted in self?.queue.async { - guard let self, self.client === capturedClient, self.packEventBatch?.id == batch.id else { return } + guard let self, self.client.map(ObjectIdentifier.init) == capturedClientID, + self.packEventBatch?.id == batch.id else { return } self.packEventsInFlight = false if accepted { self.packEventBatch = nil diff --git a/FlowlightExtension/Info.plist b/FlowlightExtension/Info.plist index 2a1cd533..485a777c 100644 --- a/FlowlightExtension/Info.plist +++ b/FlowlightExtension/Info.plist @@ -20,11 +20,12 @@ content filter at a time and holds superseded copies until a restart, so changing the extension's version on every app release forced a reboot each time. Kept fixed, an app-only update ships the version macOS already runs and replaces nothing. Bump these two ONLY when the FlowlightExtension binary itself changes - (and expect one replacement when you do). 0.15.0 adds managed rule-pack enforcement and IPC. --> + (and expect one replacement when you do). 0.15.4 keeps app-version admission app-side while the extension + validates the independently versioned snapshot protocol. --> CFBundleShortVersionString - 0.15.0 + 0.15.4 CFBundleVersion - 23 + 26 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) FLAppGroupIdentifier diff --git a/FlowlightTests/AppLifecycleTests.swift b/FlowlightTests/AppLifecycleTests.swift new file mode 100644 index 00000000..668230b0 --- /dev/null +++ b/FlowlightTests/AppLifecycleTests.swift @@ -0,0 +1,40 @@ +import AppKit +import XCTest +@testable import Flowlight + +@MainActor +final class AppLifecycleTests: XCTestCase { + override func tearDown() { + AppDelegate.resetStartupForTests() + super.tearDown() + } + + func testStartupRunsOnlyOnceAcrossRepeatedInstallations() { + AppDelegate.resetStartupForTests(didFinishLaunching: true) + var starts = 0 + + AppDelegate.installStartup { starts += 1 } + AppDelegate.installStartup { starts += 100 } + + XCTAssertEqual(starts, 1) + } + + func testLatestPrelaunchOwnerStartsOnceWhenApplicationFinishesLaunching() { + AppDelegate.resetStartupForTests() + let delegate = AppDelegate() + var starts = 0 + + AppDelegate.installStartup { starts += 100 } + AppDelegate.installStartup { starts += 1 } + XCTAssertEqual(starts, 0) + + delegate.applicationDidFinishLaunching( + Notification(name: NSApplication.didFinishLaunchingNotification) + ) + delegate.applicationDidFinishLaunching( + Notification(name: NSApplication.didFinishLaunchingNotification) + ) + + XCTAssertEqual(starts, 1) + } +} diff --git a/FlowlightTests/BoundedHTTPDataTransportTests.swift b/FlowlightTests/BoundedHTTPDataTransportTests.swift new file mode 100644 index 00000000..2cff8cb4 --- /dev/null +++ b/FlowlightTests/BoundedHTTPDataTransportTests.swift @@ -0,0 +1,149 @@ +import XCTest +@testable import Flowlight + +final class BoundedHTTPDataTransportTests: XCTestCase { + override func tearDown() { + StubURLProtocol.handler = nil + super.tearDown() + } + + func testAcceptsResponseAtByteLimit() async throws { + let body = Data("1234".utf8) + StubURLProtocol.handler = { proto, request in + proto.send(status: 200, headers: ["Content-Length": "4"], body: body, request: request) + } + let transport = makeTransport(maximumBytes: 4) + + let (data, response) = try await transport.data(for: request()) + + XCTAssertEqual(data, body) + XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200) + } + + func testRejectsDeclaredOversizeBeforeBody() async throws { + StubURLProtocol.handler = { proto, request in + proto.send(status: 200, headers: ["Content-Length": "5"], request: request) + } + let transport = makeTransport(maximumBytes: 4) + + await XCTAssertThrowsAsyncError(try await transport.data(for: request())) { error in + XCTAssertEqual(error as? BoundedHTTPDataTransport.TransportError, .responseTooLarge) + } + } + + func testRejectsStreamedOversizeWithoutContentLength() async throws { + StubURLProtocol.handler = { proto, request in + proto.send(status: 200, body: Data("12345".utf8), request: request) + } + let transport = makeTransport(maximumBytes: 4) + + await XCTAssertThrowsAsyncError(try await transport.data(for: request())) { error in + XCTAssertEqual(error as? BoundedHTTPDataTransport.TransportError, .responseTooLarge) + } + } + + func testRejectsRedirectOutsideTrustedOrigin() async throws { + StubURLProtocol.handler = { proto, request in + proto.redirect(from: request, to: URL(string: "https://other.example/catalog.json")!) + } + let transport = makeTransport(maximumBytes: 16) + + await XCTAssertThrowsAsyncError(try await transport.data(for: request())) { error in + XCTAssertEqual(error as? BoundedHTTPDataTransport.TransportError, .redirectRejected) + } + } + + func testCancellationCompletesPromptlyAndOnlyCancelsItsRequest() async throws { + StubURLProtocol.handler = { _, _ in } + let transport = makeTransport(maximumBytes: 16) + let first = Task { try await transport.data(for: request(path: "first")) } + let second = Task { try await transport.data(for: request(path: "second")) } + await Task.yield() + + first.cancel() + do { + _ = try await first.value + XCTFail("cancelled request should throw") + } catch { + XCTAssertTrue(error is CancellationError) + } + XCTAssertFalse(second.isCancelled) + second.cancel() + _ = try? await second.value + } + + func testRequestTimeoutFailsHungResponse() async throws { + StubURLProtocol.handler = { _, _ in } + let transport = BoundedHTTPDataTransport( + allowedOrigin: URL(string: "https://trusted.example")!, + maximumBytes: 16, requestTimeout: 0.05, resourceTimeout: 0.1, + protocolClasses: [StubURLProtocol.self] + ) + + await XCTAssertThrowsAsyncError(try await transport.data(for: request())) { error in + XCTAssertEqual((error as? URLError)?.code, .timedOut) + } + } + + private func makeTransport(maximumBytes: Int) -> BoundedHTTPDataTransport { + BoundedHTTPDataTransport( + allowedOrigin: URL(string: "https://trusted.example")!, + maximumBytes: maximumBytes, + protocolClasses: [StubURLProtocol.self] + ) + } + + private func request(path: String = "catalog") -> URLRequest { + URLRequest(url: URL(string: "https://trusted.example/\(path).json")!) + } +} + +private final class StubURLProtocol: URLProtocol, @unchecked Sendable { + static let lock = NSLock() + nonisolated(unsafe) static var handler: ((StubURLProtocol, URLRequest) -> Void)? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lock.lock() + let handler = Self.handler + Self.lock.unlock() + handler?(self, request) + } + + override func stopLoading() {} + + func send(status: Int, headers: [String: String] = [:], body: Data? = nil, + request: URLRequest) { + let response = HTTPURLResponse( + url: request.url!, statusCode: status, httpVersion: nil, + headerFields: headers + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if let body { client?.urlProtocol(self, didLoad: body) } + client?.urlProtocolDidFinishLoading(self) + } + + func redirect(from request: URLRequest, to destination: URL) { + let response = HTTPURLResponse( + url: request.url!, statusCode: 302, httpVersion: nil, + headerFields: ["Location": destination.absoluteString] + )! + client?.urlProtocol(self, wasRedirectedTo: URLRequest(url: destination), redirectResponse: response) + } +} + +private func XCTAssertThrowsAsyncError( + _ expression: @autoclosure () async throws -> T, + _ validate: (Error) -> Void, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + XCTFail("expected error", file: file, line: line) + } catch { + validate(error) + } +} diff --git a/FlowlightTests/ExtensionRebootTests.swift b/FlowlightTests/ExtensionRebootTests.swift index 3e3787d4..f8ae039b 100644 --- a/FlowlightTests/ExtensionRebootTests.swift +++ b/FlowlightTests/ExtensionRebootTests.swift @@ -56,6 +56,8 @@ final class ExtensionRebootTests: XCTestCase { /// on a loop, so the read has to actually find it in the bundle that hosts these tests. func testTheExpectedVersionIsReadFromTheEmbeddedExtension() { XCTAssertFalse(ExtensionManager.expectedExtensionVersion.isEmpty) + XCTAssertTrue(ExtensionManager.expectedExtensionVersion.contains("/"), + "the repair identity must include CFBundleVersion when extension code changes") } /// The held-slot state, read from what `systemextensionsctl list` actually prints — the reliable signal, since diff --git a/FlowlightTests/ExtensionRecoveryTests.swift b/FlowlightTests/ExtensionRecoveryTests.swift index c2080302..ec46dda3 100644 --- a/FlowlightTests/ExtensionRecoveryTests.swift +++ b/FlowlightTests/ExtensionRecoveryTests.swift @@ -159,11 +159,18 @@ final class ExtensionVersionTests: XCTestCase { XCTAssertEqual(ExtensionVersion.compare("0.9.0", "0.10.0"), .orderedAscending) } - /// `systemextensionsctl list` prints the short version and the build together. Whatever reads it should get - /// the same answer as the properties request, which only carries the short version. - func testTheBuildSuffixIsIgnored() { + /// A legacy caller that knows only the short version remains compatible with the full identity. + func testAnUnknownBuildSuffixDoesNotCreateAFalseMismatch() { XCTAssertEqual(ExtensionVersion.compare("0.5.0/8", "0.5.0"), .orderedSame) } + + /// The failure this release exposed: extension code changed without changing the short version. The bundle build + /// is the only fact that distinguishes the active old binary from the embedded new one. + func testDifferentBundleBuildsAreStale() { + XCTAssertEqual(ExtensionVersion.compare("0.15.4/24", "0.15.4/25"), .orderedAscending) + XCTAssertTrue(ExtensionVersion.isStale(installed: ["0.15.4/24"], appVersion: "0.15.4/25")) + XCTAssertFalse(ExtensionVersion.isStale(installed: ["0.15.4/25"], appVersion: "0.15.4/25")) + } } /// What the app does with the menu bar when it is meant to run in the background. diff --git a/FlowlightTests/ExtensionSourceTests.swift b/FlowlightTests/ExtensionSourceTests.swift index 26e7e389..cea43539 100644 --- a/FlowlightTests/ExtensionSourceTests.swift +++ b/FlowlightTests/ExtensionSourceTests.swift @@ -39,4 +39,44 @@ final class ExtensionSourceTests: XCTestCase { lock.lock(); defer { lock.unlock() } XCTAssertEqual(count, 1) } + + func testRulePackPushGateAbsorbsDuplicateStartupPush() { + var gate = RulePackPushGate() + + XCTAssertTrue(gate.begin(epoch: 2)) + XCTAssertFalse(gate.begin(epoch: 2)) + XCTAssertEqual(gate.finish(epoch: 2), false) + XCTAssertTrue(gate.begin(epoch: 2)) + } + + func testRulePackPushGateCoalescesANewerSnapshot() { + var gate = RulePackPushGate() + + XCTAssertTrue(gate.begin(epoch: 2)) + XCTAssertFalse(gate.begin(epoch: 3)) + XCTAssertFalse(gate.begin(epoch: 4)) + XCTAssertEqual(gate.finish(epoch: 2), true) + XCTAssertTrue(gate.begin(epoch: 4)) + XCTAssertNil(gate.finish(epoch: 3)) + XCTAssertEqual(gate.finish(epoch: 4), false) + } + + func testEveryPackMutationRequiresTheRegisteredXPCClient() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let source = try String( + contentsOf: root.appendingPathComponent("FlowlightExtension/IPCServer.swift"), + encoding: .utf8 + ) + for method in ["claimRule", "rulePackHello", "beginRulePack", "appendRulePack", "commitRulePack", "clearRulePacks"] { + let start = try XCTUnwrap(source.range(of: "func \(method)(")) + let body = source[start.lowerBound...].prefix(1_500) + XCTAssertTrue(body.contains("NSXPCConnection.current()"), method) + XCTAssertTrue(body.contains("self.client === connection"), method) + } + let registerStart = try XCTUnwrap(source.range(of: "func register(")) + let registerBody = source[registerStart.lowerBound...].prefix(1_500) + XCTAssertTrue(registerBody.contains("self.packTransfer.reset()")) + } } diff --git a/FlowlightTests/InspectionTests.swift b/FlowlightTests/InspectionTests.swift index 64247009..2fdcd0a3 100644 --- a/FlowlightTests/InspectionTests.swift +++ b/FlowlightTests/InspectionTests.swift @@ -234,6 +234,42 @@ final class LLMToolCallReaderTests: XCTestCase { } final class InspectionStorageTests: XCTestCase { + func testProxyOwnerLookupRetriesUntilIdentityAppears() { + let state = ProxyOwnerLookupTestState(ownerAppearsAt: 3) + + ProxyOwnerLookup.resolve( + delays: ProxyOwnerLookup.retryDelays[...], + lookup: { state.lookup() }, + schedule: { delay, work in + state.schedule(delay) + work() + }, + completion: { state.resolve($0) } + ) + + XCTAssertEqual(state.lookupCount, 3) + XCTAssertEqual(state.scheduledDelays, [0.01, 0.025]) + XCTAssertEqual(state.resolvedOwner.bundleID, "com.example.app") + } + + func testProxyOwnerLookupFailsOpenAfterBoundedRetries() { + let state = ProxyOwnerLookupTestState(ownerAppearsAt: nil) + + ProxyOwnerLookup.resolve( + delays: ProxyOwnerLookup.retryDelays[...], + lookup: { state.lookup() }, + schedule: { delay, work in + state.schedule(delay) + work() + }, + completion: { state.resolve($0) } + ) + + XCTAssertEqual(state.lookupCount, 4) + XCTAssertEqual(state.scheduledDelays, ProxyOwnerLookup.retryDelays) + XCTAssertTrue(state.resolvedOwner.bundleID.isEmpty) + } + func testNeverInspectMatchingAndPAC() { XCTAssertTrue(InspectionController.matches(host: "gateway.icloud.com", patterns: ["icloud.com"])) XCTAssertTrue(InspectionController.matches(host: "apple.com", patterns: ["*.apple.com"])) @@ -244,6 +280,34 @@ final class InspectionStorageTests: XCTestCase { XCTAssertFalse(pac.contains("alert(1)\"")) } + func testProxyFactsPutNumericDestinationsInTheAddressField() { + let owner = InspectionRecorder.Owner(bundleID: "com.example.app") + let v4 = InspectionController.proxyFlowFacts(host: "203.0.113.9", port: 443, owner: owner) + let v6 = InspectionController.proxyFlowFacts(host: "2001:0db8::1", port: 8443, owner: owner) + + XCTAssertEqual(v4.host, "") + XCTAssertEqual(v4.ip, "203.0.113.9") + XCTAssertTrue(Rule(action: .block, destination: "203.0.113.9").matches(v4)) + XCTAssertTrue(Rule(action: .block, destination: "203.0.113.0/24").matches(v4)) + XCTAssertTrue(AgentPolicy.matches("203.0.113.0/24", host: v4.host, ip: v4.ip), + "request-level proxy rules use the same destination split") + XCTAssertEqual(v6.host, "") + XCTAssertEqual(v6.ip, "2001:db8::1") + XCTAssertTrue(Rule(action: .block, destination: "2001:db8::/32").matches(v6)) + } + + func testProxyFactsKeepNamesAndExemptLocalAddresses() { + let owner = InspectionRecorder.Owner(bundleID: "com.example.app") + let named = InspectionController.proxyFlowFacts(host: "API.Example.com", port: 443, owner: owner) + let loopback = InspectionController.proxyFlowFacts(host: "127.0.0.1", port: 8080, owner: owner) + let privateAddress = InspectionController.proxyFlowFacts(host: "192.168.1.4", port: 8080, owner: owner) + + XCTAssertEqual(named.host, "API.Example.com") + XCTAssertEqual(named.ip, "") + XCTAssertTrue(BlockRules.isExempt(loopback)) + XCTAssertTrue(BlockRules.isExempt(privateAddress)) + } + func testExchangeRoundTrip() throws { let url = FileManager.default.temporaryDirectory.appendingPathComponent("x-\(UUID().uuidString).sqlite") defer { try? FileManager.default.removeItem(at: url) } @@ -270,6 +334,51 @@ final class InspectionStorageTests: XCTestCase { } } +private final class ProxyOwnerLookupTestState: @unchecked Sendable { + private let lock = NSLock() + private let ownerAppearsAt: Int? + private var lookups = 0 + private var delays: [TimeInterval] = [] + private var owner = InspectionRecorder.Owner(bundleID: "sentinel") + + init(ownerAppearsAt: Int?) { + self.ownerAppearsAt = ownerAppearsAt + } + + func lookup() -> InspectionRecorder.Owner { + lock.lock(); defer { lock.unlock() } + lookups += 1 + return lookups == ownerAppearsAt + ? InspectionRecorder.Owner(bundleID: "com.example.app") + : InspectionRecorder.Owner() + } + + func schedule(_ delay: TimeInterval) { + lock.lock(); defer { lock.unlock() } + delays.append(delay) + } + + func resolve(_ owner: InspectionRecorder.Owner) { + lock.lock(); defer { lock.unlock() } + self.owner = owner + } + + var lookupCount: Int { + lock.lock(); defer { lock.unlock() } + return lookups + } + + var scheduledDelays: [TimeInterval] { + lock.lock(); defer { lock.unlock() } + return delays + } + + var resolvedOwner: InspectionRecorder.Owner { + lock.lock(); defer { lock.unlock() } + return owner + } +} + final class ToolCallLinksTests: XCTestCase { private func exchange(_ id: Int64, at: Double, app: String, bundle: String, host: String, calls: [ToolCall] = []) -> HTTPExchange { HTTPExchange(id: id, started: Date(timeIntervalSince1970: at), duration: 0.5, scheme: "https", host: host, port: 443, method: "GET", diff --git a/FlowlightTests/MockResponseTests.swift b/FlowlightTests/MockResponseTests.swift index 38ec4e72..59152a32 100644 --- a/FlowlightTests/MockResponseTests.swift +++ b/FlowlightTests/MockResponseTests.swift @@ -323,11 +323,13 @@ final class MockRuleStorageTests: XCTestCase { /// the decision, this covers the wiring that acts on it. final class MockProxyTests: XCTestCase { /// A stand-in server that answers "real" to anything that reaches it, and says whether anything did. - private func startUpstream(reached: @escaping () -> Void) throws -> (NWListener, UInt16) { + private func startUpstream(accepted: @escaping () -> Void = {}, + reached: @escaping () -> Void) throws -> (NWListener, UInt16) { // Not the main queue: the test blocks its own thread waiting for curl, which would deadlock the stub. let queue = DispatchQueue(label: "test.upstream") let upstream = try NWListener(using: .tcp) upstream.newConnectionHandler = { conn in + accepted() conn.start(queue: queue) // "Reached" means request bytes arrived: the proxy opens the upstream socket either way. conn.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, _ in @@ -354,6 +356,16 @@ final class MockProxyTests: XCTestCase { return (proxy, try XCTUnwrap(proxy.port)) } + private func startDeniedProxy() throws -> (InspectionProxy, UInt16) { + let proxy = InspectionProxy() + proxy.authorizeConnection = { _, _, _, answer in answer(false) } + let ready = expectation(description: "proxy ready") + proxy.onStateChange = { _ in if proxy.port != nil { ready.fulfill() } } + proxy.start(port: 0) + wait(for: [ready], timeout: 10) + return (proxy, try XCTUnwrap(proxy.port)) + } + /// curl, because the proxy refuses to proxy its own process — as it should, or it would loop. private func curl(_ url: String, through proxyPort: UInt16) -> (status: String, body: String, seconds: Double) { let task = Process() @@ -415,4 +427,47 @@ final class MockProxyTests: XCTestCase { XCTAssertTrue(reached) XCTAssertNil(recorded.first?.mockRule) } + + func testDeniedPlainHTTPConnectionNeverOpensUpstream() throws { + let accepted = expectation(description: "upstream accepted") + accepted.isInverted = true + let (upstream, upstreamPort) = try startUpstream(accepted: { accepted.fulfill() }) {} + defer { upstream.cancel() } + let (proxy, proxyPort) = try startDeniedProxy() + defer { proxy.stop() } + + let result = curl("http://127.0.0.1:\(upstreamPort)/blocked", through: proxyPort) + + XCTAssertEqual(result.status, "403") + wait(for: [accepted], timeout: 0.5) + } + + func testDeniedConnectIsRejectedBeforeTunnelOpens() throws { + let accepted = expectation(description: "CONNECT upstream accepted") + accepted.isInverted = true + let (upstream, upstreamPort) = try startUpstream(accepted: { accepted.fulfill() }) {} + defer { upstream.cancel() } + let (proxy, proxyPort) = try startDeniedProxy() + defer { proxy.stop() } + + // Use another process: the proxy deliberately rejects its own process before policy authorization to avoid loops. + let client = Process() + client.executableURL = URL(fileURLWithPath: "/usr/bin/nc") + client.arguments = ["127.0.0.1", String(proxyPort)] + let input = Pipe() + let output = Pipe() + client.standardInput = input + client.standardOutput = output + client.standardError = FileHandle.nullDevice + try client.run() + input.fileHandleForWriting.write(Data( + "CONNECT 127.0.0.1:\(upstreamPort) HTTP/1.1\r\nHost: 127.0.0.1:\(upstreamPort)\r\n\r\n".utf8 + )) + try input.fileHandleForWriting.close() + let bytes = output.fileHandleForReading.readDataToEndOfFile() + client.waitUntilExit() + + XCTAssertTrue(String(decoding: bytes, as: UTF8.self).hasPrefix("HTTP/1.1 403 Forbidden")) + wait(for: [accepted], timeout: 0.5) + } } diff --git a/FlowlightTests/RulePackCatalogPersistenceTests.swift b/FlowlightTests/RulePackCatalogPersistenceTests.swift new file mode 100644 index 00000000..94d26e56 --- /dev/null +++ b/FlowlightTests/RulePackCatalogPersistenceTests.swift @@ -0,0 +1,247 @@ +import XCTest +@testable import Flowlight + +final class RulePackCatalogPersistenceTests: XCTestCase { + private let catalogID = RulePackCatalogVerifier.catalogID + private let acceptedAt = Date(timeIntervalSince1970: 1_800_000_000) + private let nextCheck = Date(timeIntervalSince1970: 1_800_086_400) + + func testCompositionOverlaysAddsAndRetainsOmittedBuiltIns() throws { + var replacement = BuiltInRulePacks.advertising + replacement.manifest.version = "2" + var added = BuiltInRulePacks.tracking + added.manifest.id = "official.added" + let catalog = verifiedCatalog( + sequence: 2, digest: String(repeating: "a", count: 64), + definitions: [added, replacement] + ) + + let composed = try RulePackCatalogComposition.compose(catalog, currentAppVersion: "0.15.4") + + XCTAssertEqual(composed.definitions.map(\.id), + ["official.added", "official.advertising", "official.telemetry", "official.tracking"]) + XCTAssertEqual(composed.definitions.first { $0.id == replacement.id }?.manifest.version, "2") + XCTAssertEqual(composed.definitions.first { $0.id == BuiltInRulePacks.telemetry.id }, + BuiltInRulePacks.telemetry) + XCTAssertEqual(composed.rawEnvelope, catalog.rawEnvelope) + XCTAssertEqual(composed.digest, catalog.digest) + } + + func testCompositionUsesRunningAppVersionForCompatibleCatalog() throws { + var definition = BuiltInRulePacks.advertising + definition.manifest.minimumAppVersion = "0.15.4" + let catalog = verifiedCatalog( + sequence: 2, + digest: String(repeating: "a", count: 64), + definitions: [definition] + ) + + XCTAssertNoThrow(try RulePackCatalogComposition.compose(catalog, currentAppVersion: "0.15.4")) + XCTAssertThrowsError(try RulePackCatalogComposition.compose(catalog, currentAppVersion: "0.15.3")) { + XCTAssertEqual($0 as? RulePackValidationError, .incompatibleProtocol(definition.id)) + } + } + + func testCatalogAcceptancePersistsExactBytesAndSurvivesReopen() throws { + let url = temporaryDatabaseURL("catalog-reopen") + var db: TrafficDatabase? = try TrafficDatabase(url: url) + let catalog = verifiedCatalog(sequence: 2, digest: String(repeating: "a", count: 64)) + + let accepted = try XCTUnwrap(db).acceptVerifiedRulePackCatalog( + catalog, etag: #""sequence-2""#, lastModified: "Mon, 19 Oct 2026 00:00:00 GMT", + checkedAt: acceptedAt, nextEligibleAt: nextCheck + ) + + XCTAssertEqual(accepted.state.receipt?.sequence, 2) + XCTAssertEqual(accepted.state.receipt?.digest, catalog.digest) + XCTAssertEqual(accepted.state.receipt?.signerKeyID, catalog.signerKeyID) + XCTAssertEqual(accepted.state.cache?.rawEnvelope, catalog.rawEnvelope) + XCTAssertEqual(accepted.state.cache?.etag, #""sequence-2""#) + XCTAssertEqual(accepted.state.cache?.lastModified, "Mon, 19 Oct 2026 00:00:00 GMT") + XCTAssertEqual(accepted.state.update.lastSuccess, acceptedAt) + XCTAssertEqual(accepted.state.update.nextEligibleAttempt, nextCheck) + XCTAssertEqual(accepted.state.update.failureCount, 0) + + db = nil + let reopened = try TrafficDatabase(url: url) + XCTAssertEqual(try reopened.loadRulePackCatalogState(catalogID: catalogID), accepted.state) + } + + func testAcceptanceTransactionRejectsRollbackAndEquivocation() throws { + let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-rollback")) + let sequenceTwo = verifiedCatalog(sequence: 2, digest: String(repeating: "2", count: 64)) + _ = try db.acceptVerifiedRulePackCatalog(sequenceTwo, checkedAt: acceptedAt, nextEligibleAt: nextCheck) + + XCTAssertThrowsError(try db.acceptVerifiedRulePackCatalog( + verifiedCatalog(sequence: 1, digest: String(repeating: "1", count: 64)), + checkedAt: acceptedAt.addingTimeInterval(1), nextEligibleAt: nextCheck + )) { error in + XCTAssertEqual(error as? RulePackCatalogPersistenceError, .rollback) + } + XCTAssertThrowsError(try db.acceptVerifiedRulePackCatalog( + verifiedCatalog(sequence: 2, digest: String(repeating: "e", count: 64)), + checkedAt: acceptedAt.addingTimeInterval(2), nextEligibleAt: nextCheck + )) { error in + XCTAssertEqual(error as? RulePackCatalogPersistenceError, .equivocation) + } + + let state = try db.loadRulePackCatalogState(catalogID: catalogID) + XCTAssertEqual(state.receipt?.sequence, 2) + XCTAssertEqual(state.receipt?.digest, sequenceTwo.digest) + XCTAssertEqual(state.cache?.rawEnvelope, sequenceTwo.rawEnvelope) + } + + func testEqualReceiptCanRecoverExactCacheBytes() throws { + let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-recovery")) + let original = verifiedCatalog(sequence: 2, digest: String(repeating: "a", count: 64), raw: Data("first".utf8)) + _ = try db.acceptVerifiedRulePackCatalog(original, etag: "old", checkedAt: acceptedAt, + nextEligibleAt: nextCheck) + let recovered = verifiedCatalog(sequence: 2, digest: original.digest, raw: Data("recovered".utf8)) + _ = try db.acceptVerifiedRulePackCatalog(recovered, etag: "new", + checkedAt: acceptedAt.addingTimeInterval(10), + nextEligibleAt: nextCheck.addingTimeInterval(10)) + + let state = try db.loadRulePackCatalogState(catalogID: catalogID) + XCTAssertEqual(state.receipt?.sequence, 2) + XCTAssertEqual(state.cache?.rawEnvelope, recovered.rawEnvelope) + XCTAssertEqual(state.cache?.etag, "new") + } + + func testAcceptanceAtomicallyInvalidatesChangedConsentButKeepsDormantRows() throws { + let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-consent")) + let original = BuiltInRulePacks.advertising + var active = RulePackConfiguration(packID: original.id, enabled: true, + scope: .selectedApps, apps: ["com.apple.Safari"]) + active.consentedVersion = original.manifest.version + active.consentedContentSHA256 = original.manifest.contentSHA256 + let dormant = RulePackConfiguration(packID: "official.omitted", enabled: false, + scope: .selectedApps, apps: ["com.apple.curl"]) + try db.saveRulePackConfiguration(active) + try db.saveRulePackConfiguration(dormant) + + var changed = original + changed.entries.append(RulePackEntry(id: UInt32(changed.entries.count + 1), host: "zz-new-ad.example", + category: .advertising)) + changed.entries.sort { "\($0.host)|\($0.match.rawValue)" < "\($1.host)|\($1.match.rawValue)" } + changed.manifest.version = "2" + changed.manifest.entryCount = changed.entries.count + changed.manifest.contentSHA256 = RulePackValidator.digest(changed.entries) + let catalog = verifiedCatalog(sequence: 2, digest: String(repeating: "b", count: 64), + definitions: [changed]) + + let accepted = try db.acceptVerifiedRulePackCatalog(catalog, checkedAt: acceptedAt, + nextEligibleAt: nextCheck) + let invalidated = try XCTUnwrap(accepted.configurations.first { $0.packID == active.packID }) + XCTAssertFalse(invalidated.enabled) + XCTAssertEqual(invalidated.scope, active.scope) + XCTAssertEqual(invalidated.apps, active.apps) + XCTAssertEqual(invalidated.consentedVersion, active.consentedVersion) + XCTAssertEqual(invalidated.consentedContentSHA256, active.consentedContentSHA256) + XCTAssertEqual(accepted.configurations.first { $0.packID == dormant.packID }, dormant) + XCTAssertEqual(try db.loadRulePackConfigurations().sorted { $0.packID < $1.packID }, + accepted.configurations) + } + + func testFailedCacheWriteRollsBackReceiptAndConsentReconciliation() throws { + let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-atomic-failure")) + var configuration = RulePackConfiguration(packID: BuiltInRulePacks.advertising.id, enabled: true) + configuration.consentedVersion = "older" + configuration.consentedContentSHA256 = String(repeating: "0", count: 64) + try db.saveRulePackConfiguration(configuration) + _ = try db.raw("DROP TABLE rule_pack_catalog_cache") { _ in 0 } + + XCTAssertThrowsError(try db.acceptVerifiedRulePackCatalog( + verifiedCatalog(sequence: 2, digest: String(repeating: "c", count: 64)), + checkedAt: acceptedAt, nextEligibleAt: nextCheck + )) + + XCTAssertEqual(try db.raw("SELECT COUNT(*) FROM rule_pack_catalog_receipts") { $0.int(0) }, [0]) + XCTAssertEqual(try db.loadRulePackConfigurations(), [configuration], + "consent must not change when catalog persistence rolls back") + } + + func testClearAllPreservesCatalogWatermarkAndCache() throws { + let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-clear")) + let catalog = verifiedCatalog(sequence: 3, digest: String(repeating: "d", count: 64)) + _ = try db.acceptVerifiedRulePackCatalog(catalog, checkedAt: acceptedAt, nextEligibleAt: nextCheck) + + try db.clearAll() + + let state = try db.loadRulePackCatalogState(catalogID: catalogID) + XCTAssertEqual(state.receipt?.sequence, 3) + XCTAssertEqual(state.receipt?.digest, catalog.digest) + XCTAssertEqual(state.cache?.rawEnvelope, catalog.rawEnvelope) + } + + func testNotModifiedRequiresTheReverifiedDurableReceipt() throws { + let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-not-modified")) + let catalog = verifiedCatalog(sequence: 4, digest: String(repeating: "4", count: 64)) + _ = try db.acceptVerifiedRulePackCatalog(catalog, checkedAt: acceptedAt, nextEligibleAt: nextCheck) + + XCTAssertThrowsError(try db.recordRulePackCatalogNotModified( + catalogID: catalogID, expectedSequence: 3, expectedDigest: catalog.digest, + checkedAt: acceptedAt.addingTimeInterval(10), nextEligibleAt: nextCheck + )) { error in + XCTAssertEqual(error as? RulePackCatalogPersistenceError, .invalidReceipt) + } + let checked = acceptedAt.addingTimeInterval(20) + try db.recordRulePackCatalogNotModified( + catalogID: catalogID, expectedSequence: catalog.sequence, expectedDigest: catalog.digest, + checkedAt: checked, nextEligibleAt: nextCheck.addingTimeInterval(20) + ) + XCTAssertEqual(try db.loadRulePackCatalogState(catalogID: catalogID).update.lastStatus, + "not-modified") + } + + func testReviewedSaveIsCheckedAgainstDurableCatalogReceipt() throws { + let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-reviewed-save")) + let definition = BuiltInRulePacks.advertising + let catalog = verifiedCatalog(sequence: 5, digest: String(repeating: "5", count: 64), + definitions: [definition]) + _ = try db.acceptVerifiedRulePackCatalog(catalog, checkedAt: acceptedAt, nextEligibleAt: nextCheck) + var configuration = RulePackConfiguration(packID: definition.id, enabled: true) + configuration.consentedVersion = definition.manifest.version + configuration.consentedContentSHA256 = definition.manifest.contentSHA256 + let current = RulePackReviewReceipt( + packID: definition.id, version: definition.manifest.version, + contentSHA256: definition.manifest.contentSHA256, + catalogSequence: catalog.sequence, catalogDigest: catalog.digest + ) + try db.saveRulePackConfiguration(configuration, reviewed: current) + + var stale = current + stale.catalogSequence -= 1 + XCTAssertThrowsError(try db.saveRulePackConfiguration(configuration, reviewed: stale)) { error in + XCTAssertEqual(error as? RulePackCatalogPersistenceError, .staleReview) + } + configuration.enabled = false + XCTAssertNoThrow(try db.saveRulePackConfiguration(configuration, reviewed: nil)) + } + + func testAsyncResultReturnsValuesAndPropagatesErrors() async throws { + let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-async")) + let value = try await db.asyncResult { _ in 42 } + XCTAssertEqual(value, 42) + do { + _ = try await db.asyncResult { _ -> Int in throw RulePackCatalogPersistenceError.invalidReceipt } + XCTFail("expected persistence error") + } catch { + XCTAssertEqual(error as? RulePackCatalogPersistenceError, .invalidReceipt) + } + } + + private func verifiedCatalog(sequence: Int64, digest: String, + raw: Data? = nil, + definitions: [RulePackDefinition] = BuiltInRulePacks.all) -> VerifiedRulePackCatalog { + VerifiedRulePackCatalog( + definitions: definitions, sequence: sequence, digest: digest, + signerKeyID: "test-key", stale: false, + rawEnvelope: raw ?? Data("catalog-\(sequence)-\(digest)".utf8) + ) + } + + private func temporaryDatabaseURL(_ name: String) -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-\(name)-\(UUID().uuidString).sqlite") + } +} diff --git a/FlowlightTests/RulePackCatalogUpdaterTests.swift b/FlowlightTests/RulePackCatalogUpdaterTests.swift new file mode 100644 index 00000000..4032e05b --- /dev/null +++ b/FlowlightTests/RulePackCatalogUpdaterTests.swift @@ -0,0 +1,474 @@ +import CryptoKit +import XCTest +@testable import Flowlight + +final class RulePackCatalogUpdaterTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + @MainActor + func testBootstrapAcceptsBundledCatalogWithoutNetwork() async throws { + let fixture = try productionFixture() + let db = try database("bootstrap") + let store = RulePackStore() + final class Counter: @unchecked Sendable { var calls = 0 } + let counter = Counter() + let updater = RulePackCatalogUpdater( + db: db, store: store, currentAppVersion: "0.15.4", + bundledCatalog: { fixture }, + transport: { _ in counter.calls += 1; throw URLError(.badServerResponse) }, + now: { self.now } + ) + + await updater.bootstrap() + + XCTAssertEqual(counter.calls, 0) + XCTAssertEqual(updater.receipt?.sequence, 1) + XCTAssertEqual(updater.status, .current) + XCTAssertEqual(store.definitions, BuiltInRulePacks.all.sorted { $0.id < $1.id }) + XCTAssertFalse(updater.isDownloadedUpdate(BuiltInRulePacks.advertising)) + XCTAssertNotNil(store.reviewReceipt(for: BuiltInRulePacks.advertising)) + } + + @MainActor + func testConditional304ReverifiesCacheAndAdvancesSchedule() async throws { + let fixture = try productionFixture() + let db = try database("not-modified") + let store = RulePackStore() + final class Capture: @unchecked Sendable { var request: URLRequest? } + let capture = Capture() + let response = HTTPURLResponse( + url: try XCTUnwrap(URL(string: RulePackCatalogVerifier.sourceURL)), + statusCode: 304, httpVersion: nil, headerFields: nil + )! + let updater = RulePackCatalogUpdater( + db: db, store: store, currentAppVersion: "0.15.4", + bundledCatalog: { fixture }, + transport: { request in capture.request = request; return (Data(), response) }, + now: { self.now } + ) + await updater.bootstrap() + _ = try db.raw("UPDATE rule_pack_catalog_cache SET etag = ?, last_modified = ?", + [.text(#""catalog-1""#), .text("Mon, 19 Oct 2026 00:00:00 GMT")]) { _ in 0 } + await updater.bootstrap() + updater.startScheduling(networkingEnabled: true) + + await updater.checkNow() + + XCTAssertEqual(capture.request?.value(forHTTPHeaderField: "If-None-Match"), #""catalog-1""#) + XCTAssertEqual(capture.request?.value(forHTTPHeaderField: "If-Modified-Since"), + "Mon, 19 Oct 2026 00:00:00 GMT") + XCTAssertEqual(capture.request?.value(forHTTPHeaderField: "User-Agent"), "Flowlight/0.15.4") + XCTAssertEqual(updater.status, .current) + XCTAssertEqual(updater.catalogState.update.lastStatus, "not-modified") + XCTAssertEqual(updater.catalogState.update.nextEligibleAttempt, + now.addingTimeInterval(RulePackCatalogUpdater.healthyInterval)) + } + + @MainActor + func testVerified200PersistsValidatorsAndInstallsNewPackDefaultOff() async throws { + let key = P256.Signing.PrivateKey() + var compatible = newDefinition() + compatible.manifest.minimumAppVersion = "0.15.4" + let data = try signedCatalog(key: key, sequence: 2, definitions: [compatible]) + let db = try database("accepted-200") + let store = RulePackStore(currentAppVersion: "0.15.4") + let response = HTTPURLResponse( + url: try XCTUnwrap(URL(string: RulePackCatalogVerifier.sourceURL)), + statusCode: 200, httpVersion: nil, + headerFields: ["Content-Type": "application/json; charset=utf-8", + "ETag": #""catalog-2""#, "Last-Modified": "Tue, 20 Oct 2026 00:00:00 GMT"] + )! + let updater = RulePackCatalogUpdater( + db: db, store: store, currentAppVersion: "0.15.4", + bundledCatalog: { data }, transport: { _ in (data, response) }, + trustedSigners: [signer(key)], now: { self.now } + ) + await updater.bootstrap() + updater.startScheduling(networkingEnabled: true) + + await updater.checkNow() + + XCTAssertEqual(updater.receipt?.sequence, 2) + XCTAssertEqual(updater.catalogState.cache?.etag, #""catalog-2""#) + XCTAssertEqual(updater.catalogState.cache?.lastModified, "Tue, 20 Oct 2026 00:00:00 GMT") + let downloaded = try XCTUnwrap(store.definitions.first { $0.id == "official.new" }) + XCTAssertTrue(updater.isDownloadedUpdate(downloaded)) + XCTAssertFalse(store.configuration(for: "official.new").enabled) + XCTAssertNil(store.snapshot) + } + + @MainActor + func testWrongMIMELeavesLastKnownGoodAndPersistsBackoff() async throws { + let fixture = try productionFixture() + let db = try database("mime-failure") + let store = RulePackStore() + let response = HTTPURLResponse( + url: try XCTUnwrap(URL(string: RulePackCatalogVerifier.sourceURL)), + statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "text/html"] + )! + let updater = RulePackCatalogUpdater( + db: db, store: store, currentAppVersion: "0.15.4", + bundledCatalog: { fixture }, transport: { _ in (fixture, response) }, + now: { self.now } + ) + await updater.bootstrap() + let prior = updater.receipt + updater.startScheduling(networkingEnabled: true) + + await updater.checkNow() + + XCTAssertEqual(updater.receipt, prior) + XCTAssertEqual(updater.catalogState.update.failureCount, 1) + XCTAssertEqual(updater.catalogState.update.nextEligibleAttempt, + now.addingTimeInterval(RulePackCatalogUpdater.failureBackoff[0])) + guard case .failed(_, let usingLastKnownGood) = updater.status else { + return XCTFail("wrong MIME should be a visible refresh failure") + } + XCTAssertTrue(usingLastKnownGood) + } + + @MainActor + func testHigherWatermarkWithMissingCacheEntersRecoveryAndOmitsValidators() async throws { + let key = P256.Signing.PrivateKey() + let sequenceTwo = try signedCatalog(key: key, sequence: 2, definitions: [newDefinition()]) + let verified = try RulePackCatalogVerifier.verify( + data: sequenceTwo, trustedSigners: [signer(key)], now: Int64(now.timeIntervalSince1970), + currentAppVersion: "0.15.4" + ) + let composed = try RulePackCatalogComposition.compose(verified, currentAppVersion: "0.15.4") + let db = try database("missing-cache") + _ = try db.acceptVerifiedRulePackCatalog( + composed, etag: "stale-validator", checkedAt: now, + nextEligibleAt: now.addingTimeInterval(60) + ) + _ = try db.raw("DELETE FROM rule_pack_catalog_cache") { _ in 0 } + let store = RulePackStore() + final class Capture: @unchecked Sendable { var request: URLRequest? } + let capture = Capture() + let response = HTTPURLResponse( + url: try XCTUnwrap(URL(string: RulePackCatalogVerifier.sourceURL)), + statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] + )! + let updater = RulePackCatalogUpdater( + db: db, store: store, currentAppVersion: "0.15.4", + bundledCatalog: { try self.productionFixture() }, + transport: { request in capture.request = request; return (sequenceTwo, response) }, + trustedSigners: [signer(key)], now: { self.now } + ) + + await updater.bootstrap() + XCTAssertTrue(store.definitions.isEmpty) + XCTAssertNil(store.snapshot) + guard case .recovery = updater.status else { return XCTFail("missing cache must require recovery") } + updater.startScheduling(networkingEnabled: true) + await updater.checkNow() + + XCTAssertNil(capture.request?.value(forHTTPHeaderField: "If-None-Match")) + XCTAssertNil(capture.request?.value(forHTTPHeaderField: "If-Modified-Since")) + XCTAssertEqual(updater.receipt?.sequence, 2) + XCTAssertEqual(updater.status, .current) + XCTAssertFalse(store.definitions.isEmpty) + } + + @MainActor + func testManualCheckBypassesScheduleAndFailureBackoffProgresses() async throws { + let fixture = try productionFixture() + let db = try database("manual-backoff") + let store = RulePackStore() + final class Clock: @unchecked Sendable { var date: Date; init(_ date: Date) { self.date = date } } + final class Counter: @unchecked Sendable { var calls = 0 } + let clock = Clock(now) + let counter = Counter() + let updater = RulePackCatalogUpdater( + db: db, store: store, currentAppVersion: "0.15.4", + bundledCatalog: { fixture }, + transport: { _ in counter.calls += 1; throw URLError(.notConnectedToInternet) }, + now: { clock.date } + ) + await updater.bootstrap() + updater.startScheduling(networkingEnabled: true) + + for (index, delay) in RulePackCatalogUpdater.failureBackoff.enumerated() { + await updater.checkNow() + XCTAssertEqual(counter.calls, index + 1, "manual checks must bypass the future due time") + XCTAssertEqual(updater.catalogState.update.failureCount, index + 1) + XCTAssertEqual(updater.catalogState.update.nextEligibleAttempt, + clock.date.addingTimeInterval(delay)) + clock.date = clock.date.addingTimeInterval(10) + } + await updater.checkNow() + XCTAssertEqual(counter.calls, 4) + XCTAssertEqual(updater.catalogState.update.failureCount, 4) + XCTAssertEqual(updater.catalogState.update.nextEligibleAttempt, + clock.date.addingTimeInterval(RulePackCatalogUpdater.failureBackoff[2])) + } + + @MainActor + func testConcurrentManualChecksShareOneInFlightRequest() async throws { + let fixture = try productionFixture() + let db = try database("one-in-flight") + let store = RulePackStore() + final class Gate: @unchecked Sendable { + var calls = 0 + var continuation: CheckedContinuation<(Data, URLResponse), Error>? + } + let gate = Gate() + let started = expectation(description: "first catalog request started") + let response = HTTPURLResponse( + url: try XCTUnwrap(URL(string: RulePackCatalogVerifier.sourceURL)), + statusCode: 304, httpVersion: nil, headerFields: nil + )! + let updater = RulePackCatalogUpdater( + db: db, store: store, currentAppVersion: "0.15.4", + bundledCatalog: { fixture }, transport: { _ in + gate.calls += 1 + started.fulfill() + return try await withCheckedThrowingContinuation { gate.continuation = $0 } + }, now: { self.now } + ) + await updater.bootstrap() + updater.startScheduling(networkingEnabled: true) + + async let first: Void = updater.checkNow() + await fulfillment(of: [started], timeout: 1) + await updater.checkNow() + XCTAssertEqual(gate.calls, 1) + gate.continuation?.resume(returning: (Data(), response)) + await first + XCTAssertEqual(updater.status, .current) + } + + @MainActor + func testAutomaticRefreshPreferencePersistsAcrossUpdaterInstances() async throws { + let fixture = try productionFixture() + let db = try database("refresh-preference") + let store = RulePackStore() + let updater = RulePackCatalogUpdater( + db: db, store: store, currentAppVersion: "0.15.4", + bundledCatalog: { fixture }, now: { self.now } + ) + await updater.bootstrap() + + await updater.setAutomaticRefresh(false) + + XCTAssertFalse(updater.automaticRefresh) + let reloaded = RulePackCatalogUpdater( + db: db, store: RulePackStore(), currentAppVersion: "0.15.4", + bundledCatalog: { fixture }, now: { self.now } + ) + await reloaded.bootstrap() + XCTAssertFalse(reloaded.automaticRefresh) + } + + @MainActor + func testNetworkingDisabledNeverInvokesTransport() async throws { + let fixture = try productionFixture() + let db = try database("disabled-network") + let store = RulePackStore() + final class Counter: @unchecked Sendable { var calls = 0 } + let counter = Counter() + let updater = RulePackCatalogUpdater( + db: db, store: store, currentAppVersion: "0.15.4", + bundledCatalog: { fixture }, + transport: { _ in counter.calls += 1; throw URLError(.notConnectedToInternet) }, + now: { self.now } + ) + await updater.bootstrap() + updater.startScheduling(networkingEnabled: false) + + await updater.checkNow() + + XCTAssertEqual(counter.calls, 0) + XCTAssertEqual(updater.status, .current) + } + + @MainActor + func testAcceptedExpiredCatalogLoadsAsStaleLastKnownGood() async throws { + let key = P256.Signing.PrivateKey() + let acceptedAt = now.addingTimeInterval(-7_200) + let data = try signedCatalog( + key: key, sequence: 2, definitions: [newDefinition()], + issuedAt: Int64(acceptedAt.timeIntervalSince1970) - 100, + expiresAt: Int64(acceptedAt.timeIntervalSince1970) + 3_600 + ) + let verified = try RulePackCatalogVerifier.verify( + data: data, trustedSigners: [signer(key)], + now: Int64(acceptedAt.timeIntervalSince1970), currentAppVersion: "0.15.4" + ) + let db = try database("expired-lkg") + _ = try db.acceptVerifiedRulePackCatalog( + try RulePackCatalogComposition.compose(verified, currentAppVersion: "0.15.4"), checkedAt: acceptedAt, + nextEligibleAt: acceptedAt.addingTimeInterval(60) + ) + let store = RulePackStore() + let updater = RulePackCatalogUpdater( + db: db, store: store, currentAppVersion: "0.15.4", + bundledCatalog: { data }, trustedSigners: [signer(key)], now: { self.now } + ) + + await updater.bootstrap() + + XCTAssertEqual(updater.status, .stale) + XCTAssertFalse(store.definitions.isEmpty) + } + + @MainActor + func testNeverAcceptedExpiredCatalogEntersRecovery() async throws { + let key = P256.Signing.PrivateKey() + let data = try signedCatalog( + key: key, sequence: 2, definitions: [newDefinition()], + issuedAt: Int64(now.timeIntervalSince1970) - 7_200, + expiresAt: Int64(now.timeIntervalSince1970) - 3_600 + ) + let store = RulePackStore() + let updater = RulePackCatalogUpdater( + db: try database("expired-cold"), store: store, currentAppVersion: "0.15.4", + bundledCatalog: { data }, trustedSigners: [signer(key)], now: { self.now } + ) + + await updater.bootstrap() + + guard case .recovery = updater.status else { return XCTFail("expired unaccepted content must fail closed") } + XCTAssertTrue(store.definitions.isEmpty) + XCTAssertNil(store.snapshot) + } + + @MainActor + func testRevokedSignerForAcceptedCacheEntersRecovery() async throws { + let key = P256.Signing.PrivateKey() + let data = try signedCatalog(key: key, sequence: 2, definitions: [newDefinition()]) + let db = try database("revoked-cache") + let verified = try RulePackCatalogVerifier.verify( + data: data, trustedSigners: [signer(key)], now: Int64(now.timeIntervalSince1970), + currentAppVersion: "0.15.4" + ) + _ = try db.acceptVerifiedRulePackCatalog( + try RulePackCatalogComposition.compose(verified, currentAppVersion: "0.15.4"), checkedAt: now, + nextEligibleAt: now.addingTimeInterval(60) + ) + var revoked = signer(key) + revoked.revoked = true + let store = RulePackStore() + let updater = RulePackCatalogUpdater( + db: db, store: store, currentAppVersion: "0.15.4", + bundledCatalog: { data }, trustedSigners: [revoked], now: { self.now } + ) + + await updater.bootstrap() + + guard case .recovery = updater.status else { return XCTFail("revoked cached signer must fail closed") } + XCTAssertTrue(store.definitions.isEmpty) + XCTAssertNil(store.snapshot) + } + + @MainActor + func testPersistenceFailureDoesNotPublishCandidateDefinitions() async throws { + let key = P256.Signing.PrivateKey() + let baseline = try signedCatalog(key: key, sequence: 1, definitions: [newDefinition()]) + var changed = newDefinition() + changed.manifest.version = "2" + let candidate = try signedCatalog(key: key, sequence: 2, definitions: [changed]) + let db = try database("failed-publication") + let store = RulePackStore() + let response = HTTPURLResponse( + url: try XCTUnwrap(URL(string: RulePackCatalogVerifier.sourceURL)), + statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] + )! + let updater = RulePackCatalogUpdater( + db: db, store: store, currentAppVersion: "0.15.4", + bundledCatalog: { baseline }, transport: { _ in (candidate, response) }, + trustedSigners: [signer(key)], now: { self.now } + ) + await updater.bootstrap() + let before = store.definitions + _ = try db.raw("DROP TABLE rule_pack_catalog_cache") { _ in 0 } + updater.startScheduling(networkingEnabled: true) + + await updater.checkNow() + + XCTAssertEqual(store.definitions, before) + XCTAssertEqual(updater.receipt?.sequence, 1) + guard case .failed(_, let usingLastKnownGood) = updater.status else { + return XCTFail("persistence failure should be visible") + } + XCTAssertTrue(usingLastKnownGood) + } + + private func productionFixture() throws -> Data { + let url = try XCTUnwrap(Bundle.main.url(forResource: "official", withExtension: "json")) + return try Data(contentsOf: url) + } + + private func database(_ name: String) throws -> TrafficDatabase { + try TrafficDatabase(url: FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-updater-\(name)-\(UUID().uuidString).sqlite")) + } + + private func signer(_ key: P256.Signing.PrivateKey) -> RulePackCatalogTrustedSigner { + RulePackCatalogTrustedSigner( + keyID: "test-key", publisher: RulePackCatalogVerifier.publisher, + catalogID: RulePackCatalogVerifier.catalogID, packIDPrefix: "official.", + publicKeyPEM: key.publicKey.pemRepresentation, + validFrom: Int64(now.timeIntervalSince1970) - 10_000, + validUntil: Int64(now.timeIntervalSince1970) + 10_000, + revoked: false + ) + } + + private func newDefinition() -> RulePackDefinition { + let entries = [RulePackEntry(id: 1, host: "new.example", category: .advertising)] + return RulePackDefinition( + manifest: RulePackManifest( + id: "official.new", version: "1", generatedAt: now.addingTimeInterval(-100), + entryCount: entries.count, contentSHA256: RulePackValidator.digest(entries), + titleKey: "pack.new.title", summaryKey: "pack.new.summary", caveatKey: "pack.new.caveat", + source: "test", license: "MIT" + ), + entries: entries + ) + } + + private func signedCatalog(key: P256.Signing.PrivateKey, sequence: Int64, + definitions: [RulePackDefinition], + issuedAt: Int64? = nil, expiresAt: Int64? = nil) throws -> Data { + let packs = definitions.sorted { $0.id < $1.id }.map { definition in + RulePackCatalogWirePack( + manifest: RulePackCatalogWireManifest( + id: definition.id, version: definition.manifest.version, + generatedAt: Int64(definition.manifest.generatedAt.timeIntervalSince1970), + entryCount: UInt32(definition.entries.count), + contentSHA256: definition.manifest.contentSHA256, + minimumAppVersion: definition.manifest.minimumAppVersion, + minimumExtensionProtocol: definition.manifest.minimumExtensionProtocol, + titleKey: definition.manifest.titleKey, summaryKey: definition.manifest.summaryKey, + caveatKey: definition.manifest.caveatKey, source: definition.manifest.source, + license: definition.manifest.license, format: definition.manifest.format + ), + entries: definition.entries + ) + } + var envelope = RulePackCatalogEnvelope( + format: RulePackCatalogVerifier.catalogFormat, + payload: RulePackCatalogPayload( + catalogID: RulePackCatalogVerifier.catalogID, + channel: RulePackCatalogVerifier.channel, + sourceURL: RulePackCatalogVerifier.sourceURL, + sequence: sequence, + issuedAt: issuedAt ?? Int64(now.timeIntervalSince1970) - 100, + expiresAt: expiresAt ?? Int64(now.timeIntervalSince1970) + 3_600, + publisher: RulePackCatalogVerifier.publisher, + packs: packs + ), + signatures: [] + ) + let signature = try key.signature(for: RulePackCatalogVerifier.canonicalPayload(for: envelope)) + envelope.signatures = [RulePackCatalogSignature( + algorithm: RulePackCatalogVerifier.signatureAlgorithm, + keyID: "test-key", + value: Data(signature.derRepresentation).base64EncodedString() + )] + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(envelope) + } +} diff --git a/FlowlightTests/RulePackEventDeliveryTests.swift b/FlowlightTests/RulePackEventDeliveryTests.swift new file mode 100644 index 00000000..7dbcdc10 --- /dev/null +++ b/FlowlightTests/RulePackEventDeliveryTests.swift @@ -0,0 +1,167 @@ +import XCTest +@testable import Flowlight + +final class RulePackEventDeliveryTests: XCTestCase { + func testRetryReusesDeliveryIdentityAndCommitsOnce() { + let batches = LockedValue<[RulePackEventBatch]>([]) + let delivered = expectation(description: "retried batch committed") + let delivery = RulePackEventDelivery(retryDelay: 0.01, attemptTimeout: 1) { batch, claim, completion in + XCTAssertTrue(claim()) + let attempt = batches.withValue { + $0.append(batch) + return $0.count + } + completion(attempt == 2) + } + + delivery.enqueue(event()) { committed in + XCTAssertTrue(committed) + delivered.fulfill() + } + wait(for: [delivered], timeout: 2) + + let captured = batches.value + XCTAssertEqual(captured.count, 2) + XCTAssertEqual(captured[0].deliverySession, captured[1].deliverySession) + XCTAssertEqual(captured[0].sequence, captured[1].sequence) + XCTAssertEqual(captured[0].events, captured[1].events) + } + + func testTwoFailedAttemptsFailOpen() { + let attempts = LockedValue(0) + let allowed = expectation(description: "refusal abandoned") + let delivery = RulePackEventDelivery(retryDelay: 0.01, attemptTimeout: 1) { _, claim, completion in + XCTAssertTrue(claim()) + attempts.withValue { $0 += 1 } + completion(false) + } + + delivery.enqueue(event()) { committed in + XCTAssertFalse(committed) + allowed.fulfill() + } + wait(for: [allowed], timeout: 2) + XCTAssertEqual(attempts.value, 2) + } + + func testMissingAcknowledgementsTimeOutAndFailOpen() { + let attempts = LockedValue(0) + let allowed = expectation(description: "timed out refusal abandoned") + let delivery = RulePackEventDelivery(retryDelay: 0.01, attemptTimeout: 0.03) { _, _, _ in + attempts.withValue { $0 += 1 } + } + + delivery.enqueue(event()) { committed in + XCTAssertFalse(committed) + allowed.fulfill() + } + wait(for: [allowed], timeout: 1) + XCTAssertEqual(attempts.value, 2) + } + + func testConcurrentRefusalsHaveIndependentDeadlines() { + let first = expectation(description: "first failed open") + let second = expectation(description: "second failed open") + let started = Date() + let delivery = RulePackEventDelivery(retryDelay: 0.01, attemptTimeout: 0.03) { _, _, _ in } + + delivery.enqueue(event()) { committed in + XCTAssertFalse(committed) + XCTAssertLessThan(Date().timeIntervalSince(started), 0.2) + first.fulfill() + } + delivery.enqueue(event()) { committed in + XCTAssertFalse(committed) + XCTAssertLessThan(Date().timeIntervalSince(started), 0.2) + second.fulfill() + } + + wait(for: [first, second], timeout: 1) + } + + func testTimedOutAttemptCannotLaterClaimADurableWrite() { + let claims = LockedValue<[RulePackEventDelivery.Claim]>([]) + let callbacks = LockedValue<[@Sendable (Bool) -> Void]>([]) + let allowed = expectation(description: "timed out refusal abandoned") + let delivery = RulePackEventDelivery(retryDelay: 0.01, attemptTimeout: 0.03) { _, claim, completion in + claims.withValue { $0.append(claim) } + callbacks.withValue { $0.append(completion) } + } + + delivery.enqueue(event()) { committed in + XCTAssertFalse(committed) + allowed.fulfill() + } + wait(for: [allowed], timeout: 1) + + let lateClaims = claims.value + let lateCallbacks = callbacks.value + XCTAssertEqual(lateClaims.count, 2) + XCTAssertTrue(lateClaims.allSatisfy { !$0() }) + lateCallbacks.forEach { $0(true) } + } + + func testInvalidateFailsAuthorizationOpenAndCancelsUnclaimedWrite() { + let sinkStarted = expectation(description: "sink started") + let authorization = expectation(description: "authorization failed open") + let invalidated = expectation(description: "delivery invalidated") + let heldClaim = LockedValue(nil) + let delivery = RulePackEventDelivery(retryDelay: 0.01, attemptTimeout: 1) { _, claim, _ in + heldClaim.withValue { $0 = claim } + sinkStarted.fulfill() + } + + delivery.enqueue(event()) { committed in + XCTAssertFalse(committed) + authorization.fulfill() + } + wait(for: [sinkStarted], timeout: 1) + delivery.invalidate { invalidated.fulfill() } + wait(for: [authorization, invalidated], timeout: 1) + + XCTAssertFalse(heldClaim.value?() ?? true) + } + + private func event() -> RulePackEvent { + RulePackEvent( + bucket: 1_700_000_000, + packID: "official.ads", + packVersion: "1", + entryID: 7, + category: .advertising, + action: .block, + count: 1, + firstAt: 1_700_000_001, + lastAt: 1_700_000_001, + bundleID: "com.google.Chrome", + appName: "Google Chrome", + agentKey: "com.google.Chrome", + agentName: "Google Chrome", + host: "ads.example", + matchedHost: "ads.example", + port: 443 + ) + } +} + +private final class LockedValue: @unchecked Sendable { + private let lock = NSLock() + private var stored: Value + + init(_ value: Value) { + stored = value + } + + var value: Value { + lock.lock() + defer { lock.unlock() } + return stored + } + + @discardableResult + func withValue(_ body: (inout Value) -> Result) -> Result { + lock.lock() + defer { lock.unlock() } + return body(&stored) + } +} diff --git a/FlowlightTests/RulePackTests.swift b/FlowlightTests/RulePackTests.swift index ceebc79a..fde6e897 100644 --- a/FlowlightTests/RulePackTests.swift +++ b/FlowlightTests/RulePackTests.swift @@ -63,6 +63,27 @@ final class RulePackTests: XCTestCase { XCTAssertEqual(matches.map(\.packID), [second.id]) } + func testProxyMatcherOnlyPublishesTheExtensionCommittedDigest() { + let definition = pack(entries: [ + RulePackEntry(id: 1, host: "ads.example", category: .advertising), + ]) + let value = snapshot(definition) + let live = LiveRulePacks() + + XCTAssertNil(live.match(host: "ads.example", bundleID: "com.example.app")) + live.commit(snapshot: value, activeDigest: "different") + XCTAssertNil(live.match(host: "ads.example", bundleID: "com.example.app")) + XCTAssertEqual(live.activeDigest, "") + + live.commit(snapshot: value, activeDigest: value.digest) + XCTAssertEqual(live.match(host: "cdn.ads.example", bundleID: "com.example.app")?.packID, definition.id) + XCTAssertEqual(live.activeDigest, value.digest) + + live.replace(snapshot: nil) + XCTAssertNil(live.match(host: "ads.example", bundleID: "com.example.app")) + XCTAssertEqual(live.activeDigest, "") + } + func testValidatorRejectsTamperDuplicateAndProtocolMismatch() throws { let valid = pack(entries: [RulePackEntry(id: 1, host: "ads.example", category: .advertising)]) XCTAssertNoThrow(try RulePackValidator.validate([valid])) @@ -90,6 +111,8 @@ final class RulePackTests: XCTestCase { XCTAssertThrowsError(try RulePackValidator.validate([newerApp], currentAppVersion: "0.15.0")) { XCTAssertEqual($0 as? RulePackValidationError, .incompatibleProtocol(valid.id)) } + XCTAssertNoThrow(try RulePackValidator.validate([newerApp]), + "the extension validates protocol compatibility after the app has admitted the snapshot") } func testSimulationReportsOnlyNewlyBlockedTraffic() throws { @@ -108,6 +131,27 @@ final class RulePackTests: XCTestCase { XCTAssertTrue(unchanged.isEmpty) } + func testSimulationIncludesOtherEnabledPacksInBaseline() { + let entry = RulePackEntry(id: 1, host: "ads.example", category: .advertising) + let candidateDefinition = pack(id: "official.candidate", entries: [entry]) + let activeDefinition = pack(id: "official.active", entries: [entry]) + let candidate = RulePackConfiguration(packID: candidateDefinition.id) + let active = RulePackConfiguration(packID: activeDefinition.id, enabled: true) + let flow = RuleSimulation.Flow( + when: Date(), agentKey: "com.apple.Safari", bundleID: "com.apple.Safari", + appName: "Safari", host: "cdn.ads.example", ip: "203.0.113.7", port: 443, + bytes: 50, connections: 2 + ) + + let result = RulePackSimulation.run( + candidate: candidate, + definitions: [activeDefinition, candidateDefinition], + configurations: [active], overrides: [], rules: [], flows: [flow] + ) + + XCTAssertTrue(result.isEmpty, "an already-enforced destination is not a newly refused connection") + } + func testSimulationUsesSafetyPauseRulesAndAgentPolicyPrecedence() { let definition = pack(entries: [ RulePackEntry(id: 1, host: "ads.example", category: .advertising), @@ -188,6 +232,38 @@ final class RulePackTests: XCTestCase { XCTAssertTrue(try reopened.loadRulePackOverrides().isEmpty) } + func testPreWatermarkFlagBatchDefaultsToDurableDelivery() throws { + let batch = activityBatch() + var object = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(batch)) as? [String: Any] + ) + object.removeValue(forKey: "requiresDurableWatermark") + + let decoded = try JSONDecoder().decode( + RulePackEventBatch.self, + from: JSONSerialization.data(withJSONObject: object) + ) + + XCTAssertTrue(decoded.requiresDurableWatermark, + "a still-running older extension needs the original durable delivery behavior") + XCTAssertEqual(decoded.id, batch.id) + XCTAssertEqual(decoded.deliverySession, batch.deliverySession) + XCTAssertEqual(decoded.sequence, batch.sequence) + XCTAssertEqual(decoded.events, batch.events) + } + + func testAppLocalPackBatchesDoNotCreatePermanentWatermarks() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-pack-local-watermark-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + var batch = activityBatch() + batch.requiresDurableWatermark = false + + XCTAssertTrue(try db.recordRulePackEventBatch(batch)) + XCTAssertTrue(try db.raw("SELECT delivery_session FROM rule_pack_event_watermarks") { $0.text(0) }.isEmpty) + XCTAssertEqual(try db.rulePackEvents(limit: 10).first?.count, 1) + } + func testStalePackEventMarkerRepairsThePhysicalUniqueKey() throws { let url = FileManager.default.temporaryDirectory .appendingPathComponent("flowlight-pack-stale-key-\(UUID().uuidString).sqlite") @@ -307,15 +383,15 @@ final class RulePackTests: XCTestCase { func testStorePublishesOnlyAfterConfigurationCommit() async throws { let url = FileManager.default.temporaryDirectory.appendingPathComponent("flowlight-pack-store-\(UUID().uuidString).sqlite") let db = try TrafficDatabase(url: url) - let store = RulePackStore() - store.attach(db: db) - try await waitUntil { store.configurations.count == store.definitions.count } + let store = try await attachedStore(db: db) var publications = [RulePackSnapshot?]() store.onChange = { publications.append($0) } - var enabled = store.configuration(for: "official.advertising") + let definition = BuiltInRulePacks.advertising + var enabled = store.configuration(for: definition.id) enabled.enabled = true - store.save(enabled) + store.save(enabled, reviewedDefinition: definition, + receipt: try XCTUnwrap(store.reviewReceipt(for: definition))) XCTAssertTrue(publications.isEmpty) try await waitUntil { publications.count == 1 } @@ -331,17 +407,15 @@ final class RulePackTests: XCTestCase { func testStoreDisablesEmptySelectedAppConfiguration() async throws { let url = FileManager.default.temporaryDirectory.appendingPathComponent("flowlight-pack-empty-scope-\(UUID().uuidString).sqlite") let db = try TrafficDatabase(url: url) - let store = RulePackStore() - store.attach(db: db) - try await waitUntil { store.configurations.count == store.definitions.count } + let store = try await attachedStore(db: db) var candidate = store.configuration(for: "official.advertising") candidate.enabled = true candidate.scope = .selectedApps candidate.apps = [" ", "\n"] var completed = false - store.save(candidate) { success in - XCTAssertTrue(success) + store.save(candidate, reviewedDefinition: BuiltInRulePacks.advertising, receipt: nil) { result in + XCTAssertEqual(result, .saved) completed = true } @@ -358,17 +432,17 @@ final class RulePackTests: XCTestCase { func testStoreSaveCompletionSeesCommittedConfiguration() async throws { let url = FileManager.default.temporaryDirectory.appendingPathComponent("flowlight-pack-completion-\(UUID().uuidString).sqlite") let db = try TrafficDatabase(url: url) - let store = RulePackStore() - store.attach(db: db) - try await waitUntil { store.configurations.count == store.definitions.count } + let store = try await attachedStore(db: db) var candidate = store.configuration(for: "official.advertising") candidate.enabled = true candidate.scope = .selectedApps candidate.apps = ["com.apple.curl"] var completed = false - store.save(candidate) { success in - XCTAssertTrue(success) + let definition = BuiltInRulePacks.advertising + store.save(candidate, reviewedDefinition: definition, + receipt: try XCTUnwrap(store.reviewReceipt(for: definition))) { result in + XCTAssertEqual(result, .saved) XCTAssertEqual(store.configuration(for: candidate.packID).scope, .selectedApps) XCTAssertEqual(store.configuration(for: candidate.packID).apps, ["com.apple.curl"]) XCTAssertNotNil(store.snapshot) @@ -467,16 +541,16 @@ final class RulePackTests: XCTestCase { func testStorePublishesAfterTransientFailureRecovers() async throws { let url = FileManager.default.temporaryDirectory.appendingPathComponent("flowlight-pack-recovery-\(UUID().uuidString).sqlite") let db = try TrafficDatabase(url: url) - let store = RulePackStore() - store.attach(db: db) - try await waitUntil { store.configurations.count == store.definitions.count } + let store = try await attachedStore(db: db) var publications = [RulePackSnapshot?]() store.onChange = { publications.append($0) } store.updateStatus(.failed("transient storage failure")) - var enabled = store.configuration(for: "official.advertising") + let definition = BuiltInRulePacks.advertising + var enabled = store.configuration(for: definition.id) enabled.enabled = true - store.save(enabled) + store.save(enabled, reviewedDefinition: definition, + receipt: try XCTUnwrap(store.reviewReceipt(for: definition))) try await waitUntil { publications.count == 1 } let saved = try db.sync { try $0.loadRulePackConfigurations() } @@ -490,13 +564,13 @@ final class RulePackTests: XCTestCase { func testClearingStoreActivityKeepsConfigurationAndExceptions() async throws { let url = FileManager.default.temporaryDirectory.appendingPathComponent("flowlight-pack-clear-\(UUID().uuidString).sqlite") let db = try TrafficDatabase(url: url) - let store = RulePackStore() - store.attach(db: db) - try await waitUntil { store.configurations.count == store.definitions.count } + let store = try await attachedStore(db: db) - var configuration = store.configuration(for: "official.advertising") + let definition = BuiltInRulePacks.advertising + var configuration = store.configuration(for: definition.id) configuration.enabled = true - store.save(configuration) + store.save(configuration, reviewedDefinition: definition, + receipt: try XCTUnwrap(store.reviewReceipt(for: definition))) let exception = RulePackOverride(packID: configuration.packID, app: "com.apple.Safari", host: "ads.example") store.addOverride(exception) let batch = RulePackEventBatch(events: [ @@ -513,7 +587,7 @@ final class RulePackTests: XCTestCase { try await waitUntil { store.configuration(for: configuration.packID).enabled } configuration = store.configuration(for: configuration.packID) - store.clearActivity() + store.clearPublishedActivityAfterDurableDeletion() XCTAssertTrue(store.events.isEmpty) XCTAssertEqual(store.deliveryStats, RulePackDeliveryStats(droppedHits: 0, droppedKeys: 0)) @@ -521,6 +595,206 @@ final class RulePackTests: XCTestCase { XCTAssertEqual(store.overrides, [exception]) } + @MainActor + func testSupersededInitialReloadStillCompletesStartupBarrier() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-pack-startup-revision-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + let store = RulePackStore() + let blockerStarted = expectation(description: "database queue blocked") + let completed = expectation(description: "startup barrier completed") + let release = DispatchSemaphore(value: 0) + db.queue.async { + blockerStarted.fulfill() + release.wait() + } + await fulfillment(of: [blockerStarted], timeout: 1) + + store.attach(db: db) { completed.fulfill() } + store.beginActivityClear() + release.signal() + + await fulfillment(of: [completed], timeout: 2) + XCTAssertTrue(store.events.isEmpty) + store.finishActivityClear(succeeded: true) + } + + @MainActor + func testDurableAcknowledgementDoesNotWaitForMainActorPublication() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-pack-durable-ack-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + let store = try await attachedStore(db: db) + let completed = DispatchSemaphore(value: 0) + let result = LockedBool() + + store.record(activityBatch()) { committed in + result.set(committed) + completed.signal() + } + + // This blocks MainActor publication. The durable acknowledgement must still come from the database queue. + XCTAssertEqual(completed.wait(timeout: .now() + 1), .success) + XCTAssertTrue(result.value) + XCTAssertEqual(try db.sync { try $0.rulePackEvents(limit: 10).count }, 1) + } + + @MainActor + func testActivityClearRemovesAPreClearBatchQueuedAheadOfDeletion() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-pack-clear-inflight-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + let store = try await attachedStore(db: db) + let blockerStarted = expectation(description: "database queue blocked") + let batchRetired = expectation(description: "pre-clear batch retired") + let release = DispatchSemaphore(value: 0) + db.queue.async { + blockerStarted.fulfill() + release.wait() + } + await fulfillment(of: [blockerStarted], timeout: 1) + + store.record(activityBatch()) { accepted in + XCTAssertTrue(accepted) + batchRetired.fulfill() + } + store.beginActivityClear() + let clear = Task { + try await db.asyncResult { try $0.clearAll() } + store.finishActivityClear(succeeded: true) + } + release.signal() + + await fulfillment(of: [batchRetired], timeout: 2) + try await clear.value + XCTAssertTrue(store.events.isEmpty) + XCTAssertTrue(try db.rulePackEvents(limit: 10).isEmpty) + } + + func testDeleteEverythingRemovesOrdinaryRuleEvents() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-rule-events-clear-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + let rule = Rule(action: .block, destination: "example.com") + try db.recordRuleEvents([ + RuleEvent( + at: 1_700_000_000, + ruleID: rule.id, + action: .block, + engine: .flow, + agentKey: "com.example.app", + agentName: "Example", + appName: "Example", + bundleID: "com.example.app", + host: "example.com", + ip: "", + port: 443 + ), + ]) + XCTAssertEqual(try db.ruleEvents(limit: 10).count, 1) + + try db.clearAll() + + XCTAssertTrue(try db.ruleEvents(limit: 10).isEmpty) + } + + @MainActor + func testActivityArrivingDuringClearIsAcknowledgedWithoutWriting() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-pack-clear-drop-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + let store = try await attachedStore(db: db) + let dropped = expectation(description: "batch acknowledged during clear") + + store.beginActivityClear() + store.record(activityBatch()) { accepted in + XCTAssertTrue(accepted) + dropped.fulfill() + } + await fulfillment(of: [dropped], timeout: 1) + store.finishActivityClear(succeeded: true) + + XCTAssertTrue(store.events.isEmpty) + XCTAssertTrue(try db.rulePackEvents(limit: 10).isEmpty) + } + + @MainActor + func testProxyActivityDuringClearFailsOpenWithoutWriting() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-pack-clear-proxy-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + let store = try await attachedStore(db: db) + let allowed = expectation(description: "proxy refusal failed open during clear") + + store.beginActivityClear() + store.record(activityBatch(), acknowledgeDuringClear: false) { committed in + XCTAssertFalse(committed) + allowed.fulfill() + } + await fulfillment(of: [allowed], timeout: 1) + store.finishActivityClear(succeeded: true) + + XCTAssertTrue(store.events.isEmpty) + XCTAssertTrue(try db.rulePackEvents(limit: 10).isEmpty) + } + + @MainActor + func testClearingActivityRejectsCatalogInstallWithCapturedEvents() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-pack-clear-race-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + let definition = BuiltInRulePacks.advertising + let catalog = VerifiedRulePackCatalog( + definitions: [definition], sequence: 1, digest: String(repeating: "1", count: 64), + signerKeyID: "test-key", stale: false, rawEnvelope: Data("one".utf8) + ) + _ = try db.acceptVerifiedRulePackCatalog( + catalog, checkedAt: Date(timeIntervalSince1970: 100), + nextEligibleAt: Date(timeIntervalSince1970: 200) + ) + let batch = RulePackEventBatch(events: [ + RulePackEvent( + bucket: 1_700_000_000, packID: definition.id, packVersion: definition.manifest.version, + entryID: 1, category: .advertising, action: .block, count: 1, + firstAt: 1_700_000_001, lastAt: 1_700_000_001, + bundleID: "com.apple.Safari", appName: "Safari", + agentKey: "com.apple.Safari", agentName: "Safari", host: "ads.example", + matchedHost: "ads.example", port: 443 + ), + ], droppedHits: 2, droppedKeys: 1) + XCTAssertTrue(try db.recordRulePackEventBatch(batch)) + + let store = RulePackStore(definitions: [definition], currentAppVersion: "0.15.4") + XCTAssertEqual( + store.installCommittedCatalog( + catalog, + acceptance: try db.loadRulePackCatalogBootstrap( + catalogID: RulePackCatalogVerifier.catalogID + ) + ), + .installed + ) + XCTAssertFalse(store.events.isEmpty) + let expectedRevision = store.catalogInstallRevision + let captured = try db.loadRulePackCatalogBootstrap( + catalogID: RulePackCatalogVerifier.catalogID + ) + + try db.clearAll() + store.clearPublishedActivityAfterDurableDeletion() + + XCTAssertEqual( + store.installCommittedCatalog( + catalog, + acceptance: captured, + expectedRevision: expectedRevision + ), + .staleRevision + ) + XCTAssertTrue(store.events.isEmpty) + XCTAssertEqual(store.deliveryStats, RulePackDeliveryStats(droppedHits: 0, droppedKeys: 0)) + } + @MainActor func testChangedPackRequiresFreshConsent() async throws { let url = FileManager.default.temporaryDirectory.appendingPathComponent("flowlight-pack-consent-\(UUID().uuidString).sqlite") @@ -542,6 +816,7 @@ final class RulePackTests: XCTestCase { try await waitUntil { store.configurations.count == 1 } XCTAssertFalse(store.configuration(for: original.id).enabled) + XCTAssertTrue(store.needsReview(original)) XCTAssertNil(store.snapshot) } @@ -551,7 +826,235 @@ final class RulePackTests: XCTestCase { XCTAssertFalse(draft.configuration.enabled) XCTAssertEqual(draft.configuration.scope, .allApps) - XCTAssertEqual(draft.requestSave(), .commit(saved)) + XCTAssertEqual(draft.requestSave(), .commit(saved, nil)) + } + + func testEditorSaveCarriesImmutableReviewedContentReceipt() { + let definition = BuiltInRulePacks.advertising + let receipt = RulePackReviewReceipt( + packID: definition.id, version: definition.manifest.version, + contentSHA256: definition.manifest.contentSHA256, + catalogSequence: 7, catalogDigest: String(repeating: "7", count: 64) + ) + let draft = RulePackEditorDraft( + saved: RulePackConfiguration(packID: definition.id), intent: .activate, + reviewedDefinition: definition, reviewReceipt: receipt + ) + + XCTAssertEqual(draft.reviewedDefinition, definition) + XCTAssertEqual(draft.requestSave(), .commit(draft.configuration, receipt)) + } + + @MainActor + func testSnapshotGenerationIsStableUntilCommittedStateChanges() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-pack-generation-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + let store = try await attachedStore(db: db) + let definition = BuiltInRulePacks.advertising + var configuration = store.configuration(for: definition.id) + configuration.enabled = true + store.save(configuration, reviewedDefinition: definition, + receipt: try XCTUnwrap(store.reviewReceipt(for: definition))) + try await waitUntil { store.snapshot != nil } + + let first = try XCTUnwrap(store.snapshot) + let second = try XCTUnwrap(store.snapshot) + XCTAssertEqual(first.generation, second.generation) + XCTAssertEqual(first, second) + + configuration = store.configuration(for: configuration.packID) + configuration.scope = .selectedApps + configuration.apps = ["com.apple.Safari"] + store.save(configuration, reviewedDefinition: definition, + receipt: try XCTUnwrap(store.reviewReceipt(for: definition))) + try await waitUntil { store.snapshot?.digest != first.digest } + XCTAssertNotEqual(store.snapshot?.generation, first.generation) + } + + @MainActor + func testRefreshBeforeEditorSaveRejectsStaleReviewedContent() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-pack-stale-review-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + let original = BuiltInRulePacks.advertising + let originalCatalog = VerifiedRulePackCatalog( + definitions: [original], sequence: 1, digest: String(repeating: "1", count: 64), + signerKeyID: "test-key", stale: false, rawEnvelope: Data("one".utf8) + ) + let firstAcceptance = try db.acceptVerifiedRulePackCatalog( + originalCatalog, checkedAt: Date(timeIntervalSince1970: 100), + nextEligibleAt: Date(timeIntervalSince1970: 200) + ) + let store = RulePackStore(definitions: [original]) + store.attach(db: db) + try await waitUntil { store.reviewReceipt(for: original) != nil } + let staleReceipt = try XCTUnwrap(store.reviewReceipt(for: original)) + + var replacement = original + replacement.entries.append(RulePackEntry( + id: UInt32(replacement.entries.count + 1), host: "zz-stale-review.example", + category: .advertising + )) + replacement.entries.sort { "\($0.host)|\($0.match.rawValue)" < "\($1.host)|\($1.match.rawValue)" } + replacement.manifest.version = "2" + replacement.manifest.entryCount = replacement.entries.count + replacement.manifest.contentSHA256 = RulePackValidator.digest(replacement.entries) + let replacementCatalog = VerifiedRulePackCatalog( + definitions: [replacement], sequence: 2, digest: String(repeating: "2", count: 64), + signerKeyID: "test-key", stale: false, rawEnvelope: Data("two".utf8) + ) + let secondAcceptance = try db.acceptVerifiedRulePackCatalog( + replacementCatalog, checkedAt: Date(timeIntervalSince1970: 300), + nextEligibleAt: Date(timeIntervalSince1970: 400) + ) + store.installCommittedCatalog(replacementCatalog, acceptance: secondAcceptance) + XCTAssertEqual(firstAcceptance.state.receipt?.sequence, 1) + + var candidate = RulePackConfiguration(packID: original.id, enabled: true) + var result: RulePackStore.SaveResult? + store.save(candidate, reviewedDefinition: original, receipt: staleReceipt) { result = $0 } + XCTAssertEqual(result, .staleReview) + XCTAssertFalse(store.configuration(for: original.id).enabled) + XCTAssertNil(store.snapshot) + candidate.enabled = false + } + + @MainActor + func testOmittedAndReintroducedPackRestoresDurableException() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-pack-reintroduced-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + var definition = BuiltInRulePacks.advertising + definition.manifest.id = "official.reintroduced" + let first = VerifiedRulePackCatalog( + definitions: [definition], sequence: 1, digest: String(repeating: "1", count: 64), + signerKeyID: "test-key", stale: false, rawEnvelope: Data("one".utf8) + ) + let firstAcceptance = try db.acceptVerifiedRulePackCatalog( + first, checkedAt: Date(timeIntervalSince1970: 100), + nextEligibleAt: Date(timeIntervalSince1970: 200) + ) + var configuration = RulePackConfiguration(packID: definition.id, enabled: true) + configuration.consentedVersion = definition.manifest.version + configuration.consentedContentSHA256 = definition.manifest.contentSHA256 + try db.saveRulePackConfiguration(configuration) + let exception = RulePackOverride( + packID: definition.id, app: "com.apple.Safari", host: definition.entries[0].host + ) + try db.saveRulePackOverride(exception) + + let store = RulePackStore(definitions: [definition], currentAppVersion: "0.15.4") + store.installCommittedCatalog(first, acceptance: try db.loadRulePackCatalogBootstrap( + catalogID: RulePackCatalogVerifier.catalogID + )) + XCTAssertEqual(firstAcceptance.state.receipt?.sequence, 1) + XCTAssertEqual(store.overrides, [exception]) + XCTAssertNotNil(store.snapshot) + + let omitted = VerifiedRulePackCatalog( + definitions: [], sequence: 2, digest: String(repeating: "2", count: 64), + signerKeyID: "test-key", stale: false, rawEnvelope: Data("two".utf8) + ) + let omissionAcceptance = try db.acceptVerifiedRulePackCatalog( + omitted, checkedAt: Date(timeIntervalSince1970: 300), + nextEligibleAt: Date(timeIntervalSince1970: 400) + ) + store.installCommittedCatalog(omitted, acceptance: omissionAcceptance) + XCTAssertTrue(store.overrides.isEmpty) + XCTAssertNil(store.snapshot) + + let reintroduced = VerifiedRulePackCatalog( + definitions: [definition], sequence: 3, digest: String(repeating: "3", count: 64), + signerKeyID: "test-key", stale: false, rawEnvelope: Data("three".utf8) + ) + let reintroductionAcceptance = try db.acceptVerifiedRulePackCatalog( + reintroduced, checkedAt: Date(timeIntervalSince1970: 500), + nextEligibleAt: Date(timeIntervalSince1970: 600) + ) + store.installCommittedCatalog(reintroduced, acceptance: reintroductionAcceptance) + + XCTAssertEqual(store.overrides, [exception]) + XCTAssertEqual(store.snapshot?.overrides, [exception]) + XCTAssertTrue(store.configuration(for: definition.id).enabled) + } + + @MainActor + func testCatalogInstallDoesNotOverwriteNewerLocalDisable() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-pack-catalog-race-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + let definition = BuiltInRulePacks.advertising + let first = VerifiedRulePackCatalog( + definitions: [definition], sequence: 1, digest: String(repeating: "1", count: 64), + signerKeyID: "test-key", stale: false, rawEnvelope: Data("one".utf8) + ) + _ = try db.acceptVerifiedRulePackCatalog( + first, checkedAt: Date(timeIntervalSince1970: 100), + nextEligibleAt: Date(timeIntervalSince1970: 200) + ) + var enabled = RulePackConfiguration(packID: definition.id, enabled: true) + enabled.consentedVersion = definition.manifest.version + enabled.consentedContentSHA256 = definition.manifest.contentSHA256 + try db.saveRulePackConfiguration(enabled) + + let store = RulePackStore(definitions: [definition], currentAppVersion: "0.15.4") + store.attach(db: db) + try await waitUntil { store.reviewReceipt(for: definition) != nil } + XCTAssertEqual( + store.installCommittedCatalog( + first, + acceptance: try db.loadRulePackCatalogBootstrap( + catalogID: RulePackCatalogVerifier.catalogID + ) + ), + .installed + ) + XCTAssertNotNil(store.snapshot) + + let expectedRevision = store.catalogInstallRevision + let refreshed = VerifiedRulePackCatalog( + definitions: [definition], sequence: 2, digest: String(repeating: "2", count: 64), + signerKeyID: "test-key", stale: false, rawEnvelope: Data("two".utf8) + ) + let staleAcceptance = try db.acceptVerifiedRulePackCatalog( + refreshed, checkedAt: Date(timeIntervalSince1970: 300), + nextEligibleAt: Date(timeIntervalSince1970: 400) + ) + + var disabled = enabled + disabled.enabled = false + await withCheckedContinuation { continuation in + store.save(disabled) { saved in + XCTAssertTrue(saved) + continuation.resume() + } + } + XCTAssertNil(store.snapshot) + XCTAssertEqual( + store.installCommittedCatalog( + refreshed, + acceptance: staleAcceptance, + expectedRevision: expectedRevision + ), + .staleRevision + ) + XCTAssertFalse(store.configuration(for: definition.id).enabled) + XCTAssertNil(store.snapshot) + + let currentRevision = store.catalogInstallRevision + XCTAssertEqual( + store.installCommittedCatalog( + refreshed, + acceptance: try db.loadRulePackCatalogBootstrap( + catalogID: RulePackCatalogVerifier.catalogID + ), + expectedRevision: currentRevision + ), + .installed + ) + XCTAssertFalse(store.configuration(for: definition.id).enabled) + XCTAssertNil(store.snapshot) } func testEditorActivationAndCancelNeverMutateSavedConfiguration() { @@ -579,7 +1082,7 @@ final class RulePackTests: XCTestCase { draft.typedApp = "com.example.second" XCTAssertEqual(draft.submitApp(installed: []), .added("com.example.second")) XCTAssertEqual(draft.configuration.apps, ["com.apple.curl", "com.example.second"]) - XCTAssertEqual(draft.requestSave(), .commit(draft.configuration), + XCTAssertEqual(draft.requestSave(), .commit(draft.configuration, nil), "Return only edits the draft; explicit Save decides whether to commit") } @@ -658,7 +1161,7 @@ final class RulePackTests: XCTestCase { var selected = RulePackConfiguration(packID: "official.advertising", enabled: true, scope: .selectedApps, apps: ["com.apple.curl"]) var draft = RulePackEditorDraft(saved: selected, intent: .review) - XCTAssertEqual(draft.requestSave(), .commit(selected)) + XCTAssertEqual(draft.requestSave(), .commit(selected, nil)) draft.configuration.scope = .allApps XCTAssertEqual(draft.requestSave(), .confirmBroadening) @@ -755,6 +1258,29 @@ final class RulePackTests: XCTestCase { } } + private func activityBatch() -> RulePackEventBatch { + RulePackEventBatch(events: [ + RulePackEvent( + bucket: 1_700_000_000, + packID: "official.advertising", + packVersion: "1", + entryID: 1, + category: .advertising, + action: .block, + count: 1, + firstAt: 1_700_000_001, + lastAt: 1_700_000_001, + bundleID: "com.google.Chrome", + appName: "Google Chrome", + agentKey: "com.google.Chrome", + agentName: "Google Chrome", + host: "ads.example", + matchedHost: "ads.example", + port: 443 + ), + ]) + } + private func event(version: String, category: RulePackEntry.Category, matchedHost: String, agentKey: String) -> RulePackEvent { RulePackEvent(bucket: 1_700_000_000, packID: "official.ads", packVersion: version, entryID: 1, @@ -764,6 +1290,26 @@ final class RulePackTests: XCTestCase { matchedHost: matchedHost, port: 443) } + @MainActor + private func attachedStore(db: TrafficDatabase) async throws -> RulePackStore { + let fixture = try XCTUnwrap(Bundle.main.url(forResource: "official", withExtension: "json")) + let data = try Data(contentsOf: fixture) + let verified = try RulePackCatalogVerifier.verify( + data: data, now: 1_800_000_000, currentAppVersion: "0.15.4" + ) + let composed = try RulePackCatalogComposition.compose(verified, currentAppVersion: "0.15.4") + _ = try db.acceptVerifiedRulePackCatalog( + composed, + checkedAt: Date(timeIntervalSince1970: 1_800_000_000), + nextEligibleAt: Date(timeIntervalSince1970: 1_800_086_400) + ) + let store = RulePackStore() + store.attach(db: db) + try await waitUntil { store.configurations.count == store.definitions.count + && store.reviewReceipt(for: BuiltInRulePacks.advertising) != nil } + return store + } + @MainActor private func waitUntil(_ condition: @escaping @MainActor () -> Bool) async throws { let deadline = Date().addingTimeInterval(2) @@ -790,3 +1336,20 @@ final class RulePackTests: XCTestCase { overrides: []) } } + +private final class LockedBool: @unchecked Sendable { + private let lock = NSLock() + private var stored = false + + var value: Bool { + lock.lock() + defer { lock.unlock() } + return stored + } + + func set(_ value: Bool) { + lock.lock() + stored = value + lock.unlock() + } +} diff --git a/FlowlightTests/RuleTests.swift b/FlowlightTests/RuleTests.swift index b639c4a5..d24a5385 100644 --- a/FlowlightTests/RuleTests.swift +++ b/FlowlightTests/RuleTests.swift @@ -3,6 +3,7 @@ import XCTest /// The rule engine, which decides whether a connection happens. Everything here is plain data in and a verdict /// out, so the whole of it can be checked without a filter, a proxy or a database. +@MainActor final class RuleTests: XCTestCase { private let session = "this-run" @@ -200,6 +201,522 @@ final class RuleTests: XCTestCase { XCTAssertEqual(RuleBook.decide(facts(), rules: [block, allow], session: session).verdict, .block) } + func testLiveProxyConsumesFiniteHitRulesExactlyOnce() { + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + let block = Rule(action: .block, destination: "example.com") + let live = LiveRules() + live.replace([block, allow], pausedUntil: nil) + + let first = live.flowDecision(facts()) + XCTAssertEqual(first.decision.rule?.id, allow.id) + XCTAssertEqual(first.decision.verdict, .allow) + XCTAssertNil(first.externalClaim) + + let second = live.flowDecision(facts()) + XCTAssertEqual(second.decision.rule?.id, block.id) + XCTAssertEqual(second.decision.verdict, .block) + } + + func testLiveProxyCanPreviewAndReconcileAnExtensionClaim() { + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + let block = Rule(action: .block, destination: "example.com") + let live = LiveRules() + live.replace([block, allow], pausedUntil: nil, generation: 7) + _ = live.beginExternalClaimAuthority( + with: RuleSet(rules: [block, allow], generation: 7, session: RuleStore.session) + ) + + let preview = live.flowDecision(facts()) + XCTAssertEqual(preview.decision.rule?.id, allow.id) + XCTAssertEqual(preview.externalClaim, RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 7)) + live.markBoundedRuleClaimed(RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 7)) + XCTAssertEqual(live.flowDecision(facts()).decision.rule?.id, block.id) + + live.replace([block, allow], pausedUntil: nil, generation: 8) + live.markBoundedRuleSpent(RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 8)) + XCTAssertEqual(live.flowDecision(facts()).decision.rule?.id, block.id) + } + + func testOrdinaryPublicationCannotReopenAConsumedFiniteRule() { + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 2 + let block = Rule(action: .block, destination: "example.com") + let live = LiveRules() + live.replace([block, allow], pausedUntil: nil, generation: 1) + + live.markBoundedRuleClaimed(RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 1)) + live.markBoundedRuleClaimed(RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 1)) + live.replace([block, allow], pausedUntil: nil, generation: 2) + + XCTAssertEqual(live.flowDecision(facts()).decision.rule?.id, block.id) + } + + func testExplicitResetMayLowerALiveFiniteRuleCount() { + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + let block = Rule(action: .block, destination: "example.com") + let live = LiveRules() + live.replace([block, allow], pausedUntil: nil, generation: 1) + live.markBoundedRuleClaimed(RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 1)) + + live.replace([block, allow], pausedUntil: nil, generation: 2, resetHitCounts: [allow.id]) + + XCTAssertEqual(live.flowDecision(facts()).decision.rule?.id, allow.id) + } + + func testStaleClaimRepliesDoNotUndoAnExplicitReset() { + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + let live = LiveRules() + live.replace([allow], pausedUntil: nil, generation: 4) + _ = live.beginExternalClaimAuthority( + with: RuleSet(rules: [allow], generation: 4, session: RuleStore.session) + ) + let stale = try! XCTUnwrap(live.flowDecision(facts()).externalClaim) + + live.replace([allow], pausedUntil: nil, generation: 5, resetHitCounts: [allow.id]) + live.markBoundedRuleClaimed(stale) + live.markBoundedRuleSpent(stale) + + XCTAssertEqual(live.flowDecision(facts()).externalClaim, + RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 5)) + } + + func testExtensionClaimSurvivesAnUnrelatedRuleEdit() { + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + let unrelated = Rule(action: .block, destination: "other.example.com") + let set = RuleSet(rules: [allow, unrelated], generation: 5, session: RuleStore.session, + ruleInvalidatedAt: [allow.id: 1, unrelated.id: 5]) + + XCTAssertTrue( + set.accepts(RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 1)) + ) + } + + func testExtensionClaimDoesNotSurviveSameRuleInvalidation() { + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + let set = RuleSet(rules: [allow], generation: 5, session: RuleStore.session, + ruleInvalidatedAt: [allow.id: 5]) + + XCTAssertFalse( + set.accepts(RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 4)) + ) + } + + func testClaimReplySurvivesAnUnrelatedRuleEdit() { + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + let unrelated = Rule(action: .block, destination: "other.example.com") + let live = LiveRules() + live.replace([allow, unrelated], pausedUntil: nil, generation: 4) + _ = live.beginExternalClaimAuthority( + with: RuleSet(rules: [allow, unrelated], generation: 4, session: RuleStore.session) + ) + let claim = try! XCTUnwrap(live.flowDecision(facts()).externalClaim) + + var edited = unrelated + edited.name = "Edited" + live.replace( + [allow, edited], + pausedUntil: nil, + generation: 5, + ruleInvalidatedAt: [unrelated.id: 5] + ) + live.markBoundedRuleClaimed(claim) + + XCTAssertNil(live.flowDecision(facts()).externalClaim) + } + + @MainActor + func testStaleRuleEventDoesNotUndoAnExplicitReset() { + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + let live = LiveRules() + let store = RuleStore() + store.save(allow) + let staleGeneration = store.ruleSet.generation + store.resetHits(allow) + + store.record([ + RuleEvent( + at: 1_700_000_000, + ruleID: allow.id, + ruleSession: RuleStore.session, + ruleGeneration: staleGeneration, + action: .allow, + engine: .flow, + agentKey: "com.example.app", + agentName: "Example", + appName: "Example", + bundleID: "com.example.app", + host: "api.example.com", + ip: "", + port: 443 + ), + ]) + live.replace(store.rules, pausedUntil: nil, generation: store.ruleSet.generation) + + XCTAssertEqual(store.rules.first?.hits, 0) + XCTAssertEqual(live.flowDecision(facts()).decision.rule?.id, allow.id) + } + + func testStaleRuleEventIsNotPersistedAfterReset() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-rule-stale-event-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + let store = RuleStore() + store.attach(db: db) + try await waitUntil { !store.rules.isEmpty || store.ruleSet.generation > 0 } + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + store.save(allow) + let staleGeneration = store.ruleSet.generation + store.resetHits(allow) + + store.record([ + RuleEvent( + at: 1_700_000_000, + ruleID: allow.id, + ruleSession: RuleStore.session, + ruleGeneration: staleGeneration, + action: .allow, + engine: .flow, + agentKey: "com.example.app", + agentName: "Example", + appName: "Example", + bundleID: "com.example.app", + host: "api.example.com", + ip: "", + port: 443 + ), + ]) + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertTrue(try db.sync { try $0.ruleEvents(limit: 10).isEmpty }) + XCTAssertTrue(store.events.isEmpty) + } + + @MainActor + func testRuleEventSurvivesAnUnrelatedRuleEdit() { + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + var unrelated = Rule(action: .block, destination: "other.example.com") + let store = RuleStore() + store.save(allow) + let claimGeneration = store.ruleSet.generation + store.save(unrelated) + unrelated.name = "Edited" + store.save(unrelated) + + store.record([ + RuleEvent( + at: 1_700_000_000, + ruleID: allow.id, + ruleSession: RuleStore.session, + ruleGeneration: claimGeneration, + action: .allow, + engine: .flow, + agentKey: "com.example.app", + agentName: "Example", + appName: "Example", + bundleID: "com.example.app", + host: "api.example.com", + ip: "", + port: 443 + ), + ]) + + XCTAssertEqual(store.rules.first(where: { $0.id == allow.id })?.hits, 1) + } + + func testPublishedRuleEventsClearAfterDurableDeletion() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-rule-events-published-clear-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + let store = RuleStore() + store.attach(db: db) + try await waitUntil { !store.rules.isEmpty || store.ruleSet.generation > 0 } + let rule = Rule(action: .block, destination: "example.com") + store.save(rule) + store.record([ + RuleEvent( + at: 1_700_000_000, + ruleID: rule.id, + action: .block, + engine: .flow, + agentKey: "com.example.app", + agentName: "Example", + appName: "Example", + bundleID: "com.example.app", + host: "example.com", + ip: "", + port: 443 + ), + ]) + try await waitUntil { !store.events.isEmpty } + XCTAssertEqual(store.events.count, 1) + + try await db.asyncResult { try $0.clearAll() } + store.clearPublishedEventsAfterDurableDeletion() + + XCTAssertTrue(store.events.isEmpty) + XCTAssertTrue(try db.sync { try $0.ruleEvents(limit: 10).isEmpty }) + } + + func testAcceptedBoundedClaimPersistsCountAndEventBeforeCompletion() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-rule-accepted-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + let store = RuleStore() + store.attach(db: db) + try await waitUntil { !store.rules.isEmpty || store.ruleSet.generation > 0 } + + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + store.save(allow) + let claim = RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: store.ruleSet.generation) + let event = RuleEvent( + at: 1_700_000_000, + ruleID: allow.id, + ruleSession: claim.session, + ruleGeneration: claim.generation, + action: .allow, + engine: .flow, + agentKey: "com.example.app", + agentName: "Example", + appName: "Example", + bundleID: "com.example.app", + host: "api.example.com", + ip: "", + port: 443 + ) + let completed = expectation(description: "accepted claim committed") + + store.recordBoundedClaim(claim, event: event) { persisted in + XCTAssertTrue(persisted) + completed.fulfill() + } + await fulfillment(of: [completed], timeout: 2) + + let loaded = try db.sync { try $0.loadRules() } + XCTAssertEqual(loaded.first(where: { $0.id == allow.id })?.hits, 1) + XCTAssertEqual(try db.sync { try $0.ruleEvents(ruleID: allow.id).count }, 1) + XCTAssertEqual(store.rules.first(where: { $0.id == allow.id })?.hits, 1) + XCTAssertNil(store.live.flowDecision(facts()).externalClaim) + } + + func testAcceptedBoundedClaimFailsOpenWhenDurableStorageIsUnavailable() async { + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + let store = RuleStore() + store.save(allow) + let claim = RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: store.ruleSet.generation) + let event = RuleEvent( + at: 1_700_000_000, + ruleID: allow.id, + ruleSession: claim.session, + ruleGeneration: claim.generation, + action: .allow, + engine: .flow, + agentKey: "com.example.app", + agentName: "Example", + appName: "Example", + bundleID: "com.example.app", + host: "api.example.com", + ip: "", + port: 443 + ) + let completed = expectation(description: "accepted claim failed open") + + store.recordBoundedClaim(claim, event: event) { persisted in + XCTAssertFalse(persisted) + completed.fulfill() + } + await fulfillment(of: [completed], timeout: 2) + + XCTAssertEqual(store.rules.first(where: { $0.id == allow.id })?.hits, 1) + XCTAssertNil(store.live.flowDecision(facts()).externalClaim) + } + + func testUncertainClaimFailsOpenWhenDurableStorageIsUnavailable() async { + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + let store = RuleStore() + store.save(allow) + let claim = RuleClaim( + ruleID: allow.id, + session: RuleStore.session, + generation: store.ruleSet.generation + ) + let completed = expectation(description: "retirement completed") + var saved: Bool? + + store.retireBoundedClaim(claim) { result in + saved = result + completed.fulfill() + } + await fulfillment(of: [completed], timeout: 2) + + XCTAssertEqual(saved, false) + XCTAssertEqual(store.rules.first(where: { $0.id == allow.id })?.hits, 1) + XCTAssertNil(store.live.flowDecision(facts()).externalClaim) + } + + func testUncertainClaimIsPersistedSpentBeforeCompletion() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-rule-uncertain-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + let store = RuleStore() + store.attach(db: db) + try await waitUntil { !store.rules.isEmpty || store.ruleSet.generation > 0 } + + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + store.save(allow) + let claim = RuleClaim( + ruleID: allow.id, + session: RuleStore.session, + generation: store.ruleSet.generation + ) + let persisted = expectation(description: "uncertain claim persisted") + + store.retireBoundedClaim(claim) { saved in + XCTAssertTrue(saved) + persisted.fulfill() + } + await fulfillment(of: [persisted], timeout: 2) + + XCTAssertEqual(store.rules.first(where: { $0.id == allow.id })?.hits, 1) + let loaded = try db.sync { try $0.loadRules() } + XCTAssertEqual(loaded.first(where: { $0.id == allow.id })?.hits, 1) + } + + func testUncertainClaimSurvivesAnUnrelatedEditAndPersistsSpent() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-rule-unrelated-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + let store = RuleStore() + store.attach(db: db) + try await waitUntil { !store.rules.isEmpty || store.ruleSet.generation > 0 } + + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + var unrelated = Rule(action: .block, destination: "other.example.com") + store.save(allow) + let claim = RuleClaim( + ruleID: allow.id, + session: RuleStore.session, + generation: store.ruleSet.generation + ) + store.save(unrelated) + unrelated.name = "Edited" + store.save(unrelated) + let persisted = expectation(description: "claim persisted across unrelated edit") + + store.retireBoundedClaim(claim) { saved in + XCTAssertTrue(saved) + persisted.fulfill() + } + await fulfillment(of: [persisted], timeout: 2) + + XCTAssertEqual(store.rules.first(where: { $0.id == allow.id })?.hits, 1) + let loaded = try db.sync { try $0.loadRules() } + XCTAssertEqual(loaded.first(where: { $0.id == allow.id })?.hits, 1) + } + + func testExternalAuthorityReceivesLocallyConsumedHitsAtHandoff() { + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 2 + let live = LiveRules() + live.replace([allow], pausedUntil: nil, generation: 12) + XCTAssertEqual(live.flowDecision(facts()).decision.rule?.id, allow.id) + + let transferred = live.beginExternalClaimAuthority( + with: RuleSet(rules: [allow], generation: 12, session: RuleStore.session) + ) + + XCTAssertEqual(transferred.rules.first?.hits, 1) + XCTAssertEqual(live.flowDecision(facts()).externalClaim, + RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 12)) + } + + func testExternalAuthorityRemainsStickyAcrossSourceFallback() { + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + let live = LiveRules() + live.replace([allow], pausedUntil: nil, generation: 12) + _ = live.beginExternalClaimAuthority( + with: RuleSet(rules: [allow], generation: 12, session: RuleStore.session) + ) + + let afterFallback = live.flowDecision(facts()) + + XCTAssertEqual(afterFallback.externalClaim, + RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 12)) + XCTAssertEqual(live.flowDecision(facts()).externalClaim, + afterFallback.externalClaim, + "fallback must fail open through unavailable claims, not resume a second counter") + } + + func testRuleClaimRoundTripKeepsSessionIdentity() throws { + let claim = RuleClaim( + ruleID: UUID(), + session: "new-app-process", + generation: 1 + ) + + let decoded = try JSONDecoder().decode( + RuleClaim.self, + from: JSONEncoder().encode(claim) + ) + + XCTAssertEqual(decoded, claim) + XCTAssertNotEqual( + decoded, + RuleClaim( + ruleID: claim.ruleID, + session: "old-app-process", + generation: claim.generation + ) + ) + } + + func testExternalAuthorityOwnsFiniteRuleDuringSynchronization() { + var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com") + allow.maxHits = 1 + let live = LiveRules() + live.replace([allow], pausedUntil: nil, generation: 12) + _ = live.beginExternalClaimAuthority( + with: RuleSet(rules: [allow], generation: 12, session: RuleStore.session) + ) + + let first = live.flowDecision(facts()) + let second = live.flowDecision(facts()) + + XCTAssertEqual(first.externalClaim, RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 12)) + XCTAssertEqual(second.externalClaim, first.externalClaim) + XCTAssertEqual(second.decision.rule?.id, allow.id, + "the proxy must not spend independently while the extension owns the counter") + } + + private func waitUntil( + timeout: TimeInterval = 2, + _ condition: @escaping @MainActor () -> Bool + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while !condition() { + if Date() >= deadline { + XCTFail("condition did not become true") + return + } + try await Task.sleep(for: .milliseconds(10)) + } + } + // MARK: Which engine can carry a rule out func testAPathMakesItTheProxysJob() { diff --git a/FlowlightTests/TrafficMonitorRulePackStartupTests.swift b/FlowlightTests/TrafficMonitorRulePackStartupTests.swift new file mode 100644 index 00000000..f60e322a --- /dev/null +++ b/FlowlightTests/TrafficMonitorRulePackStartupTests.swift @@ -0,0 +1,118 @@ +import XCTest +@testable import Flowlight + +final class TrafficMonitorRulePackStartupTests: XCTestCase { + @MainActor + func testTrafficSourceStartsAfterCatalogAndExceptionsReload() async throws { + let url = temporaryDatabaseURL("startup-order") + let db = try TrafficDatabase(url: url) + let definition = BuiltInRulePacks.advertising + let verified = try productionCatalog() + _ = try db.acceptVerifiedRulePackCatalog( + try RulePackCatalogComposition.compose(verified, currentAppVersion: "0.15.4"), checkedAt: Date(), + nextEligibleAt: Date().addingTimeInterval(86_400) + ) + var configuration = RulePackConfiguration(packID: definition.id, enabled: true) + configuration.consentedVersion = definition.manifest.version + configuration.consentedContentSHA256 = definition.manifest.contentSHA256 + try db.saveRulePackConfiguration(configuration) + let exception = RulePackOverride( + packID: definition.id, app: "com.apple.Safari", host: definition.entries[0].host + ) + try db.saveRulePackOverride(exception) + + let source = RecordingTrafficSource() + let monitor = TrafficMonitor(databaseURL: url, trafficSourceFactory: { source }) + XCTAssertFalse(source.started) + + monitor.start() + try await waitUntil { source.started } + + let snapshot = try XCTUnwrap(source.rulePackSnapshots.last ?? nil) + XCTAssertEqual(snapshot.configurations.map(\.packID), [definition.id]) + XCTAssertEqual(snapshot.overrides, [exception]) + XCTAssertEqual(monitor.rulePackCatalog.receipt?.sequence, verified.sequence) + } + + @MainActor + func testTestModeBootstrapDoesNotInvokeCatalogTransport() async throws { + let source = RecordingTrafficSource() + let monitor = TrafficMonitor( + databaseURL: temporaryDatabaseURL("no-network"), + trafficSourceFactory: { source } + ) + + monitor.start() + try await waitUntil { source.started } + + XCTAssertFalse(monitor.rulePackCatalog.networkingEnabled) + XCTAssertFalse(monitor.rulePacks.definitions.isEmpty) + XCTAssertNotNil(monitor.rulePackCatalog.receipt) + } + + @MainActor + func testSupersededExtensionCannotPublishALatePackCommit() async throws { + let oldExtension = ExtensionTrafficSource() + let replacement = RecordingTrafficSource() + var sourceCount = 0 + let monitor = TrafficMonitor( + databaseURL: temporaryDatabaseURL("stale-extension-status"), + trafficSourceFactory: { + sourceCount += 1 + return sourceCount == 1 ? oldExtension : replacement + } + ) + + monitor.start() + try await waitUntil { sourceCount == 1 && oldExtension.onRulePackStatus != nil } + monitor.reconnectSource() + try await waitUntil { replacement.started } + + oldExtension.onRulePackStatus?(.init(status: .committed, activeDigest: "stale")) + try await Task.sleep(for: .milliseconds(20)) + + XCTAssertEqual(monitor.rulePacks.status, .off) + } + + private func productionCatalog() throws -> VerifiedRulePackCatalog { + let url = try XCTUnwrap(Bundle.main.url(forResource: "official", withExtension: "json")) + return try RulePackCatalogVerifier.verify( + data: Data(contentsOf: url), + trustedSigners: RulePackCatalogVerifier.productionTrustedSigners, + now: 1_800_000_000, currentAppVersion: "0.15.4" + ) + } + + private func temporaryDatabaseURL(_ name: String) -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-monitor-\(name)-\(UUID().uuidString).sqlite") + } + + @MainActor + private func waitUntil(_ predicate: @escaping () -> Bool) async throws { + for _ in 0..<200 { + if predicate() { return } + try await Task.sleep(for: .milliseconds(10)) + } + XCTFail("timed out waiting for monitor startup") + } +} + +private final class CallCounter: @unchecked Sendable { + var calls = 0 +} + +private final class RecordingTrafficSource: TrafficSource { + let displayName = "Test source" + private(set) var started = false + private(set) var rulePackSnapshots: [RulePackSnapshot?] = [] + + func start(sink: @escaping ([TrafficBatch]) -> Void, + status: @escaping (String) -> Void) { + started = true + status("ready") + } + + func stop() {} + func setRulePacks(_ snapshot: RulePackSnapshot?) { rulePackSnapshots.append(snapshot) } +} diff --git a/FlowlightTests/UpdateTests.swift b/FlowlightTests/UpdateTests.swift index 59f75d74..417698df 100644 --- a/FlowlightTests/UpdateTests.swift +++ b/FlowlightTests/UpdateTests.swift @@ -139,6 +139,30 @@ final class UpdateTests: XCTestCase { XCTAssertFalse(checker.showWindow) } + @MainActor + func testReleaseLookupUsesExplicitRequestHeaders() async { + final class Capture: @unchecked Sendable { var request: URLRequest? } + let capture = Capture() + let data = Data(#"{"tag_name":"v1.0.0","name":"Flowlight","body":"","html_url":"https://example.invalid","draft":false,"prerelease":false,"assets":[]}"#.utf8) + let response = HTTPURLResponse( + url: URL(string: "https://api.github.com/repos/xinbetween/flowlight/releases/latest")!, + statusCode: 200, httpVersion: nil, headerFields: nil + )! + let checker = UpdateChecker { request in + capture.request = request + return (data, response) + } + + await checker.check(userInitiated: false) + + XCTAssertEqual(capture.request?.url, response.url) + XCTAssertEqual(capture.request?.httpMethod, "GET") + XCTAssertEqual(capture.request?.value(forHTTPHeaderField: "User-Agent"), + "Flowlight/\(checker.currentVersion)") + XCTAssertEqual(capture.request?.value(forHTTPHeaderField: "Accept"), + "application/vnd.github+json") + } + func testReleaseNotesAreReadable() { let notes = "**Early preview.** Hi\n\n### What's new\n- One\n- Two\n\n### Checksums (SHA-256)\n```\nabc Flowlight.dmg\n```" let text = ReleaseNotes.readable(notes) diff --git a/README.md b/README.md index 59bd4da4..3e4f01a1 100644 --- a/README.md +++ b/README.md @@ -128,8 +128,9 @@ An allowlist warns by default. Turn on **Refuse connections outside the list** f connections as well, recording each one as an alert with **Allow … from Now On** to add it to the list in a click. It needs the Network Extension — refusing a connection means being in its path, which the sampler can't do. Local -traffic, DNS, essential Apple system services and Flowlight's own connections are never refused, enforcement lapses a few seconds -after the app stops running, and HTTPS inspection doesn't combine with it. +traffic, DNS, essential Apple system services and Flowlight's own connections are never refused, and enforcement lapses a few seconds +after the app stops running. When HTTPS inspection proxies an app, destination rules and enabled managed packs are decided against the +original app before Flowlight opens the origin connection; agent allowlists still apply only on the direct Network Extension path. ### Rules An allowlist is one agent's idea of where it may go. A **rule** is the general version: it names a subject — an app, @@ -150,10 +151,15 @@ Reports, AI Agents or Inspect to write one about what you're looking at; all of ### Managed ad and tracker packs Flowlight includes three official hostname lists: **Advertising**, **Cross-site tracking**, and **Privacy and telemetry**. -Their baseline is built into the signed app for offline use and published separately as a purpose-signed official catalog; -0.15.3 establishes that publisher boundary without downloading updates in the app yet. All three are visible in -**Rules › Packs** and off by default. Enable a pack for every app or only selected apps; enforcement needs the Network -Extension, because the sampler can observe connections but cannot refuse them. +Their baseline is built into the signed app for offline use and the app checks one fixed HTTPS source for newer +publisher-signed catalogs. Automatic checks are on by default and can be switched off in **Rules › Packs**; no traffic, +pack settings, or device identifier is sent. The server sees the Mac's IP address and `Flowlight/` user agent. + +All three packs are visible in **Rules › Packs** and off by default. A valid signature authenticates content but never +grants enforcement consent: new packs remain off, and changed destinations disable that pack until you review the exact +new version and press Save. A verified accepted catalog remains available as visible stale last-known-good during an +outage; rollback, equivocation, revoked trust, or corrupt cached bytes fail closed. Enable a pack for every app or only +selected apps; enforcement needs the Network Extension, because the sampler can observe connections but cannot refuse them. Before enabling or broadening a pack, replay it against up to seven days of retained traffic to see which apps, destinations, connection counts and bytes would have changed. The activity feed groups repeated blocks instead of @@ -296,8 +302,9 @@ appears in Live and Reports like any other app's traffic, including a collector No account, no cloud, no telemetry. Everything stays in a local SQLite database. Packet capture reads only DNS answers and TLS ClientHellos (a kernel filter drops everything else), and **packet contents are never stored**. Naming who owns an IP with no known hostname is **on by default**: it sends those public IPs (never private ones) to -Team Cymru's DNS service, and you can turn it off in Capture. The daily update check asks GitHub for the latest release, and you can switch it off in -Settings. To attribute tools and MCP servers, Flowlight reads process command lines and your agents' MCP config files +Team Cymru's DNS service, and you can turn it off in Capture. Release and official rule-pack checks make standard HTTPS +requests with no traffic or pack settings; each has its own off switch in Settings or Rules › Packs. To attribute tools +and MCP servers, Flowlight reads process command lines and your agents' MCP config files on your Mac; none of it leaves your Mac. HTTPS inspection is off unless you turn it on, and what it records stays in the same local database. diff --git a/SECURITY.md b/SECURITY.md index 23bcc076..8c5b37aa 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -59,3 +59,13 @@ Key rotation has an overlap period: an app release first trusts both keys, the f later app release removes the old public key. Revocation likewise requires an app release removing the affected key; a signed feed cannot revoke the key that authenticates it. After rollback, expiry, or key compromise, corrected content is published with a new higher sequence rather than changing history or reusing a sequence. + +The app consumes only `https://flowlight.xinbetween.com/packs/v1/official.json`; there is no user-supplied source or +unsigned fallback. It bounds response size and redirects, verifies exact cached bytes again at launch, and keeps its +highest accepted sequence independently from those bytes so cache loss cannot authorize rollback. An already accepted +catalog may remain active after expiry as visibly stale last-known-good, but a revoked signer or unverifiable cache +fails closed until the same or a higher trusted catalog is recovered. + +A valid publisher signature is not user consent. New packs default off, and any changed version or destination digest +removes that pack from enforcement while retaining its saved scope and exceptions. Re-enabling requires an explicit Save +bound to the exact catalog and pack content the editor displayed; a concurrent refresh makes the old Save stale. diff --git a/Shared/IPCProtocol.swift b/Shared/IPCProtocol.swift index 88c83fb7..a12486f0 100644 --- a/Shared/IPCProtocol.swift +++ b/Shared/IPCProtocol.swift @@ -20,6 +20,12 @@ import Foundation /// like it the extension keeps none of it across a launch: a rule nobody is left to record is not enforced. func setRules(payload: Data, withReply reply: @escaping (Int) -> Void) + /// Atomically spends one bounded flow rule on behalf of the inspection proxy. The extension is the single claim + /// authority while it is active, so a direct flow and a proxied flow cannot both consume "allow once". `payload` + /// is `BlockCoding.encode(RuleClaim)` and binds the claim to the exact app session and whole-list generation the + /// proxy previewed. The reply is 1 when claimed, 0 when already spent, and -1 when that publication no longer matches. + func claimRule(payload: Data, withReply reply: @escaping (Int) -> Void) + /// Negotiates the independently-versioned pack protocol before any bytes move. func rulePackHello(appProtocol: Int, withReply reply: @escaping (Data) -> Void) /// Starts one generation. Chunks are accepted only for this header and never affect the active matcher yet. @@ -46,5 +52,5 @@ import Foundation func ruleDecisions(payload: Data, reply: @escaping () -> Void) /// Bounded minute rollups from managed packs. True is returned only after the batch is durably committed. - func rulePackEvents(payload: Data, reply: @escaping (Bool) -> Void) + func rulePackEvents(payload: Data, reply: @escaping @Sendable (Bool) -> Void) } diff --git a/Shared/Rule.swift b/Shared/Rule.swift index a41df111..da22d7cd 100644 --- a/Shared/Rule.swift +++ b/Shared/Rule.swift @@ -308,6 +308,9 @@ extension Rule { struct RuleEvent: Codable, Sendable, Equatable { var at: Int64 var ruleID: UUID + /// Present for generation-bound flow decisions. Buffered older/request events remain decodable without it. + var ruleSession: String? = nil + var ruleGeneration: UInt64? = nil var action: Rule.Action var engine: Rule.Engine var agentKey: String @@ -326,10 +329,31 @@ struct RuleEvent: Codable, Sendable, Equatable { /// The enforcing half of the rule list, as the extension is told it: the rules themselves plus the one global /// escape hatch. Sent whole on every change, like `AgentPolicy`, so the extension never has to reconcile a delta. +struct RuleClaim: Codable, Sendable, Equatable { + var ruleID: UUID + /// Identifies the app process that published this generation; generations restart from zero on every launch. + var session: String + var generation: UInt64 +} + struct RuleSet: Codable, Sendable, Equatable { var rules: [Rule] = [] + /// Changes on every whole-list publication. Per-rule boundaries below distinguish relevant from unrelated changes. + var generation: UInt64 = 0 /// Nothing is refused before this moment. The escape hatch is what makes strict rules usable. var pausedUntil: Date? /// The token `.session` schedules are measured against. var session = "" + /// The only hit counters allowed to move backwards in the extension. Ordinary event-driven publications can + /// arrive out of order with proxy claims and must preserve the newer runtime count. + var resetHitCounts: Set = [] + /// The first generation describing each rule's current definition. A claim from an older whole-list generation + /// remains valid across unrelated edits, but never across an edit, reset, or delete of the claimed rule itself. + var ruleInvalidatedAt: [UUID: UInt64] = [:] + + func accepts(_ claim: RuleClaim) -> Bool { + session == claim.session && + claim.generation <= generation && + claim.generation >= (ruleInvalidatedAt[claim.ruleID] ?? 0) + } } diff --git a/Shared/RulePack.swift b/Shared/RulePack.swift index 3d1af9a3..d206cd4e 100644 --- a/Shared/RulePack.swift +++ b/Shared/RulePack.swift @@ -181,13 +181,45 @@ struct RulePackEvent: Codable, Equatable, Sendable { /// One immutable delivery. Its ID makes an uncertain acknowledgement safe to retry without double-counting. struct RulePackEventBatch: Codable, Equatable, Sendable { - var id = UUID() + var id: UUID /// Stable for one extension process. Sequence is strictly increasing because only one batch is ever in flight. - var deliverySession = UUID() - var sequence: Int64 = 1 + var deliverySession: UUID + var sequence: Int64 + /// Extension deliveries survive uncertain cross-process acknowledgements. App-local proxy deliveries do not need + /// a permanent watermark: their database callback and authorization state live in the same process. + var requiresDurableWatermark: Bool var events: [RulePackEvent] - var droppedHits: Int64 = 0 - var droppedKeys: Int64 = 0 + var droppedHits: Int64 + var droppedKeys: Int64 + + init(id: UUID = UUID(), deliverySession: UUID = UUID(), sequence: Int64 = 1, + requiresDurableWatermark: Bool = true, events: [RulePackEvent], + droppedHits: Int64 = 0, droppedKeys: Int64 = 0) { + self.id = id + self.deliverySession = deliverySession + self.sequence = sequence + self.requiresDurableWatermark = requiresDurableWatermark + self.events = events + self.droppedHits = droppedHits + self.droppedKeys = droppedKeys + } + + private enum CodingKeys: String, CodingKey { + case id, deliverySession, sequence, requiresDurableWatermark, events, droppedHits, droppedKeys + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(UUID.self, forKey: .id) + deliverySession = try values.decode(UUID.self, forKey: .deliverySession) + sequence = try values.decode(Int64.self, forKey: .sequence) + // The app can upgrade while the prior extension is still running. Its batches predate this field and need + // the original extension behavior: durable cross-process deduplication. + requiresDurableWatermark = try values.decodeIfPresent(Bool.self, forKey: .requiresDurableWatermark) ?? true + events = try values.decode([RulePackEvent].self, forKey: .events) + droppedHits = try values.decode(Int64.self, forKey: .droppedHits) + droppedKeys = try values.decode(Int64.self, forKey: .droppedKeys) + } } enum RulePackValidationError: Error, Equatable { @@ -205,18 +237,21 @@ enum RulePackValidationError: Error, Equatable { } enum RulePackValidator { - static let appVersion = "0.15.0" - + /// App-owned callers pass the running app version. The extension omits it because snapshots have already crossed + /// that trust boundary; its compatibility contract is the independently versioned extension protocol. static func validate(_ definitions: [RulePackDefinition], extensionProtocol: UInt16 = RulePackSnapshot.protocolVersion, - currentAppVersion: String = appVersion) throws { + currentAppVersion: String? = nil) throws { var packIDs = Set() for definition in definitions { let manifest = definition.manifest guard manifest.format == RulePackManifest.formatVersion else { throw RulePackValidationError.unsupportedFormat(manifest.id) } - guard manifest.minimumExtensionProtocol <= extensionProtocol, - compareVersions(manifest.minimumAppVersion, currentAppVersion) != .orderedDescending else { + guard manifest.minimumExtensionProtocol <= extensionProtocol else { + throw RulePackValidationError.incompatibleProtocol(manifest.id) + } + if let currentAppVersion, + compareVersions(manifest.minimumAppVersion, currentAppVersion) == .orderedDescending { throw RulePackValidationError.incompatibleProtocol(manifest.id) } guard !manifest.id.isEmpty, diff --git a/docs/404.html b/docs/404.html index 9cb32587..385afde5 100644 --- a/docs/404.html +++ b/docs/404.html @@ -86,7 +86,7 @@

That page isn't here

Try the home page,

diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f0e493d6..ba3be94a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -157,8 +157,10 @@ sits in the path of a connection, and a switch that did nothing would be worse t - Every refusal travels back as a `BlockEvent` and becomes a **Connection blocked** alert naming the agent, the process, the destination and the fact that it didn't get there. That alert's context menu offers *Allow … from Now On*, which adds the destination to the agent's list and pushes the change straight back to the filter. -- A flow through the HTTPS inspection proxy leaves the Mac as Flowlight's own traffic, which is never refused, so - blocking and inspection don't compose for the same agent. +- A flow through the HTTPS inspection proxy leaves the Mac as exempt Flowlight traffic. Before opening that upstream + connection, the proxy attributes the original client and applies destination rules plus the exact managed-pack snapshot + acknowledged by the extension. Pack refusal waits for its durable activity record and fails open if that record cannot + commit. Agent allowlists remain extension-only and do not compose with the proxied leg. - `BlockRules` is plain functions over plain data (like `AgentPolicy.matches`), so `BlockingTests` covers the whole decision without a filter installed. @@ -332,6 +334,26 @@ cannot revoke its own signing authority. If compromise is suspected, stop publis resume with a higher sequence signed by a trusted key. Clients reject rollback by sequence, so restoring an older catalog always means republishing its contents under a new, higher sequence. +### Consuming the official rule-pack feed + +`RulePackCatalogUpdater` is app-owned and has one compiled HTTPS source. It uses an ephemeral cookieless session, +requires same-origin redirects and JSON, caps both declared and streamed response bytes, sends persisted ETag and +Last-Modified validators, and permits only one request at a time. Healthy automatic checks run daily; failures back off +from one hour to six hours and then 24 hours. Manual checks bypass only that schedule. Demo and XCTest startup establish +the local trusted catalog but do not use the network unless a test explicitly injects a transport. + +The receipt watermark and exact envelope cache are separate SQLite rows. Startup reverifies the exact accepted bytes +before saved configuration or exceptions can produce a snapshot, and capture does not start until that reload completes. +If a higher watermark has missing, corrupt, expired-before-acceptance, or newly untrusted bytes, definitions and the +snapshot stay empty while recovery fetches without validators; the bundled lower sequence is never silently restored. +An already accepted envelope may reverify after expiry as stale last-known-good. + +Acceptance runs in `BEGIN IMMEDIATE`: rollback/equivocation checks, exact bytes, validators, scheduling state, and consent +invalidation commit together. Only then may `RulePackStore` publish definitions or a snapshot. New packs default off; +changed content preserves saved scope, app selection, exceptions, and historical activity but is effectively disabled. +Every enabling Save carries the immutable pack and catalog receipt displayed when the editor opened, and SQLite checks +the durable catalog again inside the save transaction. + ## Releasing ```sh diff --git a/docs/about/index.html b/docs/about/index.html index dc495c4d..fcbba1bf 100644 --- a/docs/about/index.html +++ b/docs/about/index.html @@ -153,7 +153,7 @@

Thanks

diff --git a/docs/de/about/index.html b/docs/de/about/index.html index 930fdc4a..37b39102 100644 --- a/docs/de/about/index.html +++ b/docs/de/about/index.html @@ -152,7 +152,7 @@

Dank

diff --git a/docs/de/docs/index.html b/docs/de/docs/index.html index cea0056d..2fab58c0 100644 --- a/docs/de/docs/index.html +++ b/docs/de/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

Dokumentation

Flowlight benutzen

-

Alles vom ersten Start bis zum Feinschliff an den Agentenregeln. Flowlight 0.15.3, macOS 15 oder neuer.

+

Alles vom ersten Start bis zum Feinschliff an den Agentenregeln. Flowlight 0.15.4, macOS 15 oder neuer.

diff --git a/docs/de/index.html b/docs/de/index.html index f8bca5c2..bc4d5ea1 100644 --- a/docs/de/index.html +++ b/docs/de/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

Sieh, was deine Apps im Netz tun.
brew install --cask xinbetween/tap/flowlight

-

v0.15.3macOS 15+UniversalGPL-3.0Keine Telemetrie

+

v0.15.4macOS 15+UniversalGPL-3.0Keine Telemetrie

@@ -236,6 +236,7 @@

Sieh, wie KI-Agenten das Netzwerk nutzen

Software, der du nicht vertraust – siehe das Bedrohungsmodell.

Cursor ⇥ raw.githubusercontent.com · 4-mal verweigert · wochentags 18:00–09:00

+
⛔ Opt-in

Verwaltete Werbe- und Tracker-Pakete

Drei offizielle Pakete sind integriert, sichtbar und standardmäßig deaktiviert. Flowlight prüft einen einzigen festgelegten HTTPS-Feed auf signierte Updates; neue oder geänderte Inhalte werden erst durchgesetzt, nachdem du genau diese Version geprüft und Sichern gedrückt hast. Verifizierte, akzeptierte Inhalte können bei einem Ausfall als sichtbar veralteter letzter bekannter funktionsfähiger Stand erhalten bleiben.

Sie blockieren nach Hostname und können daher keine Erstanbieterwerbung, Lücken im DOM oder Layout, Browser-Overlays, skriptgesteuerten Pop-ups oder Fenster nativer Apps entfernen.

⛔ Verweigert

Schutzregeln für die Tools eines Agenten

@@ -435,7 +436,7 @@

Ein Wächter, den du nicht bewachen musst

Bleibt auf deinem Mac

Eine lokale SQLite-Datei. Kein Konto, keine Cloud-Synchronisierung, keine Telemetrie.

Standardmäßig nur Metadaten

Hostnamen, Ports und Byte-Zahlen. HTTPS zu entschlüsseln ist ein eigener Modus, den du einschalten musst – und er bleibt auf deinem Mac.

Im Kernel gefiltert

Die Erfassung lässt nur DNS-Antworten und TLS-ClientHellos durch. Alles andere wird verworfen, bevor Flowlight es sieht.

-
Abfragen, die du steuerst

Um zu benennen, wem eine Adresse gehört, gehen nur öffentliche IPs ohne Hostnamen an Team Cymru – abschaltbar mit einem Klick. Die tägliche Update-Prüfung fragt GitHub nur nach der neuesten Version und lässt sich ebenfalls abschalten.

+
Abfragen, die du steuerst

Um zu benennen, wem eine Adresse gehört, gehen nur öffentliche IPs ohne Hostnamen an Team Cymru. Release- und signierte Regelpaket-Prüfungen senden Standard-HTTPS-Anfragen ohne Verkehrs- oder Paketeinstellungen. Jede Prüfung hat einen eigenen Abschalter.

@@ -571,7 +572,7 @@

Wisse, was deinen Mac verlässt.

diff --git a/docs/de/privacy/index.html b/docs/de/privacy/index.html index 888c0bec..94fb0d62 100644 --- a/docs/de/privacy/index.html +++ b/docs/de/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -125,6 +125,7 @@

Netzwerkanfragen, die die App stellt

AnfrageGesendet anWas sie enthältWann Update-Prüfungapi.github.comEine normale HTTPS-Anfrage nach der neuesten Version; GitHub sieht deine IP-Adresse und den User-Agent Flowlight/<version>Beim Start und täglich, ab 0.1.2. Abschalten in Einstellungen › Allgemein Update-Downloadgithub.comDas Image und seine PrüfsummendateiNur wenn du auf „Herunterladen & öffnen“ klickst + Offizielle Regelpaket-Aktualisierungflowlight.xinbetween.comEine standardmäßige HTTPS-Anfrage an den festgelegten offiziellen Feed. Der Server sieht deine IP-Adresse, den User-Agent Flowlight/<version> und bedingte Cache-Validatoren; er erhält weder Datenverkehr noch Paketeinstellungen oder eine GerätekennungNach dem Start, sobald eine Prüfung fällig ist, und danach bei störungsfreiem Betrieb höchstens einmal täglich. Standardmäßig aktiviert; Ausschalter und manuelle Option Jetzt suchen unter Regeln › Pakete Reverse-DNSDer von dir eingerichtete DNS-ResolverEine Abfrage des registrierten Namens einer IP-Adresse, wenn kein Hostname gesehen wurdeNach Bedarf, im Erfassungsmodus des Samplers, der Standard ist, bis die Netzwerkerweiterung läuft. Kein Schalter zum Abschalten: ohne sie kann der Sampler die meisten Verbindungen nicht benennen. Die Netzwerkerweiterung benennt sie aus dem Datenverkehr selbst und macht diese Abfrage nicht Abfrage des NetzinhabersTeam Cymru, über DNSÖffentliche IP-Adressen ohne bekannten Hostnamen. Private und reservierte Adressen verlassen deinen Mac nieStandardmäßig ein. Abschalten in Erfassung › Netzinhaber bestimmen Fragen mit einem gehosteten ModellDer Anbieter, den du wählst: Anthropic, OpenAI, Google Gemini oder ein kompatibler EndpunktDeine Frage und die Ergebnisse der nur lesenden Flowlight-Abfragen, die das Modell ausgewählt hat. Die Verkehrsdatenbank selbst wird nicht hochgeladenNur wenn du einen gehosteten Anbieter wählst und eine Frage abschickst. Der Bildschirm Fragen zeigt die gesendeten Anfragen @@ -188,7 +189,7 @@

Kontakt

diff --git a/docs/de/threat-model/index.html b/docs/de/threat-model/index.html index 7c4b70f7..48dbd7b2 100644 --- a/docs/de/threat-model/index.html +++ b/docs/de/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -155,38 +155,35 @@

Was der Inspektion entgeht

welches von beidem gilt.

Was Blockieren ist und was nicht

-

Eine Regel, die Verbindungen verweigert, wird von der Netzwerkerweiterung ausgeführt, im Weg der Verbindung, bevor sie zustande kommt. Eine - Regel, die eine URL nennt, führt stattdessen der Inspektions-Proxy aus, der die Anfrage mit einem Status beantwortet, den die App lesen kann. - Das sind die einzigen zwei Teile von Flowlight, die handeln statt zu berichten – der nettop-Sampler kann überhaupt nichts verweigern, auf einem - Mac, der mit dem Sampler erfasst, wird also keine Regel durchgesetzt, und Flowlight sagt das, statt einen Schalter anzubieten, der nichts tut.

+

Eine Regel, die Verbindungen verweigert, wird normalerweise von der Netzwerkerweiterung ausgeführt, im Weg der Verbindung, bevor sie zustande kommt. + Bei Datenverkehr, der durch die HTTPS-Inspektion geleitet wird, wendet der lokale Proxy Zielregeln und aktivierte verwaltete Pakete auf den + ursprünglichen Client an, bevor er die Verbindung zum Ursprungsserver öffnet; eine Regel, die eine URL nennt, führt er nach der Entschlüsselung + ebenfalls dort aus und antwortet mit einem Status, den die App lesen kann. Das sind die einzigen zwei Teile von Flowlight, die handeln statt zu + berichten – der nettop-Sampler kann überhaupt nichts verweigern, auf einem Mac, der mit dem Sampler erfasst, wird also keine Regel durchgesetzt, + und Flowlight sagt das, statt einen Schalter anzubieten, der nichts tut.

Verhindern ist es trotzdem nicht. Ein Agent, der eine Shell ausführen kann, kann von Hand tun, was ein verweigertes Tool getan hätte. Eine Verweigerung, die du mit einem Klick aufheben kannst, ist eine Verweigerung, durch die der nächste Versuch eines Agenten hindurchgehen kann. Lokaler Datenverkehr, DNS, Apples eigene Dienste und Flowlights eigene Verbindungen werden nie verweigert, denn ein blockierter Mac ist ein schlechteres Ergebnis als ein blockierter Agent. Und die Durchsetzung endet wenige Sekunden nachdem Flowlight nicht mehr läuft: Eine Verweigerung, die niemand sehen kann, ist schlimmer als keine.

-

Inspektion und Verweigerung lassen sich nicht verbinden. Datenverkehr, der über Flowlights eigenen Proxy geleitet wird, - verlässt den Mac als Flowlights Datenverkehr, und Flowlight verweigert sich selbst nie – eine App, deren Anfragen du liest, ist also eine App, - deren Verbindungen Regeln nicht mehr stoppen. Die Inspektion einzuschalten, um mehr zu sehen, kostet dich die Durchsetzung, und auf dem - Bildschirm stand das vorher nirgends.

+

Die Inspektion erhält die Verweigerung nach Hostnamen, mit einer Grenze. Datenverkehr, der durch Flowlights Proxy geleitet wird, + verlässt den Mac als ausgenommener Flowlight-Datenverkehr. Deshalb entscheidet der Proxy zuerst anhand der ursprünglichen App-Identität über + gewöhnliche Zielregeln und aktivierte verwaltete Pakete. Das funktioniert auch bei angepinnten oder nie entschlüsselten Tunneln, weil CONNECT den + Host nennt. Agenten-Erlaubnislisten bleiben eine Entscheidung der Netzwerkerweiterung und gelten nicht für diesen über den Proxy geleiteten Weg + zum Ursprungsserver; Datenverkehr ohne vertrauenswürdigen Eigentümer oder Hostnamen wird im Zweifel zugelassen, statt unsichtbar verweigert zu werden.

Was Flowlight selbst aufbewahrt

Metadaten zum Datenverkehr in einer lokalen SQLite-Datenbank, mit veröffentlichten Aufbewahrungsfristen. Bei eingeschalteter HTTPS-Inspektion außerdem Anfrage- und Antwort-Bodys für drei Tage – das Sensibelste, was es überhaupt aufbewahrt. Header mit Zugangsdaten werden nach Name und nach Wortbestandteil unlesbar gemacht, was eine Heuristik ist und keine Garantie; ein Header, der ein Geheimnis unter einem Namen trägt, den nichts erkennt, kann aufgezeichnet werden.

-

Die Datenbank mit dem Datenverkehr wird nie hochgeladen. Drei kleinere Dinge verlassen den Mac, ohne dass jemand danach fragt, alle drei auf der - Datenschutzseite benannt: die Update-Prüfung, die GitHub beim Start und täglich nach der neuesten Version - fragt; die Abfrage zum Netzinhaber, die Team Cymru nach öffentlichen Adressen ohne Hostnamen fragt; und, solange Flowlight Zähler liest - statt seine Erweiterung zu betreiben, eine Reverse-DNS-Abfrage der Adressen, zu denen es keinen Namen gesehen hat. Die ersten beiden haben - Schalter zum Abschalten. Die dritte nicht, denn auf dem Sampler ist sie fast der einzige Weg, auf dem eine Verbindung überhaupt zu einem - Namen kommt – sie geht an den Resolver, den der Mac schon benutzt, und sie hört auf, sobald die Erweiterung übernimmt und Verbindungen aus dem - Datenverkehr selbst benennt. Fragen mit einem gehosteten Modell sendet weit mehr als alle drei und ist das eine, das aus bleibt, bis du es - einrichtest.

+

Die Datenbank mit dem Datenverkehr wird nie hochgeladen. Die Datenschutzseite nennt jede ausgehende Anfrage: Release- und signierte Regelpaket-Prüfungen, die optionale Abfrage zum Netzinhaber und Reverse-DNS im Sampler. Die ersten drei haben Abschalter; Reverse-DNS nicht, weil der Sampler sonst die meisten Verbindungen nicht benennen könnte. Fragen mit einem gehosteten Modell senden weit mehr und bleiben aus, bis du sie einrichtest.

Updates

Versionen sind mit einer Developer ID signiert und notarisiert. Die Updatefunktion verlangt eine veröffentlichte Prüfsumme und verweigert einen Download ohne sie, und prüft dann vor der Installation, dass die heruntergeladene App vom selben Team signiert ist wie die laufende Kopie. Ein Update, das jemand anderes signiert hat, wird verweigert, auch wenn es sonst gültig ist.

+

Offizielle Regelpakete verwenden eine separate zweckgebundene Signatur und eine einzige festgelegte HTTPS-Quelle. Die App verwaltet unabhängig davon die höchste Sequenznummer, verifiziert beim Start die exakten zwischengespeicherten Bytes erneut, weist Rollbacks und widersprüchliche Veröffentlichungen zurück und behandelt eine Herausgebersignatur niemals als Erlaubnis, geänderte Inhalte durchzusetzen. Neue oder geänderte Pakete erfordern eine ausdrückliche Prüfung des exakten Inhalts und Sichern; veraltete akzeptierte Inhalte werden als letzter bekannter funktionsfähiger Stand gekennzeichnet, während ein Vertrauensentzug oder beschädigte Bytes sicherheitshalber zur Sperrung führen.

Eine Schwachstelle melden

Nutze GitHubs private Schwachstellenmeldung oder schreib an @@ -228,7 +225,7 @@

Eine Schwachstelle melden

diff --git a/docs/docs/index.html b/docs/docs/index.html index 0e19558f..425b6746 100644 --- a/docs/docs/index.html +++ b/docs/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

Documentation

Using Flowlight

-

Everything from the first launch to tuning the agent rules. Flowlight 0.15.3, macOS 15 or later.

+

Everything from the first launch to tuning the agent rules. Flowlight 0.15.4, macOS 15 or later.

diff --git a/docs/es/about/index.html b/docs/es/about/index.html index a1b9a651..1981064d 100644 --- a/docs/es/about/index.html +++ b/docs/es/about/index.html @@ -152,7 +152,7 @@

Agradecimientos

diff --git a/docs/es/docs/index.html b/docs/es/docs/index.html index fbe8b1d0..897c640c 100644 --- a/docs/es/docs/index.html +++ b/docs/es/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

Documentación

Usar Flowlight

-

Todo, desde el primer arranque hasta el ajuste de las reglas de agentes. Flowlight 0.15.3, macOS 15 o posterior.

+

Todo, desde el primer arranque hasta el ajuste de las reglas de agentes. Flowlight 0.15.4, macOS 15 o posterior.

diff --git a/docs/es/index.html b/docs/es/index.html index e98c76fc..c42b1c21 100644 --- a/docs/es/index.html +++ b/docs/es/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

Observa la actividad de red de tus apps.

brew install --cask xinbetween/tap/flowlight

-

v0.15.3macOS 15+UniversalGPL-3.0Sin telemetría

+

v0.15.4macOS 15+UniversalGPL-3.0Sin telemetría

@@ -236,6 +236,7 @@

Vigila cómo usan la red los agentes de IA

software en el que no — mira el modelo de amenazas.

Cursor ⇥ raw.githubusercontent.com · rechazada 4 veces · de lunes a viernes 18:00–09:00

+
⛔ Previa activación

Paquetes gestionados de publicidad y rastreadores

Hay tres paquetes oficiales integrados, visibles y desactivados de forma predeterminada. Flowlight consulta un único feed HTTPS fijo para buscar actualizaciones firmadas; el contenido nuevo o modificado no se aplica hasta que revises esa versión exacta y pulses Guardar. Durante una interrupción, el contenido verificado y aceptado puede conservarse como la última versión válida conocida, marcada visiblemente como obsoleta.

Bloquean por nombre de host, por lo que no pueden eliminar anuncios propios del sitio, huecos del DOM o del diseño, superposiciones del navegador, ventanas emergentes generadas por scripts ni ventanas de aplicaciones nativas.

⛔ Rechaza

Salvaguardas sobre las herramientas de un agente

@@ -435,7 +436,7 @@

Un monitor que no tienes que vigilar

Se queda en tu Mac

Un único archivo SQLite local. Sin cuenta, sin sincronización en la nube, sin telemetría.

Metadatos por defecto

Nombres de host, puertos y bytes. Descifrar HTTPS es un modo aparte que tienes que activar, y también se queda en tu Mac.

Filtrado en el núcleo

La captura solo deja pasar las respuestas DNS y los ClientHello de TLS. Todo lo demás se descarta antes de que Flowlight lo vea.

-
Consultas que tú controlas

Para saber de quién es una dirección solo se envían a Team Cymru las IP públicas sin nombre de host, y se desactiva con un clic. La comprobación diaria de actualizaciones solo pregunta a GitHub por la última versión, y también puede desactivarse.

+
Consultas que tú controlas

Para saber de quién es una dirección solo se envían a Team Cymru las IP públicas sin nombre de host. Las comprobaciones de versiones y de paquetes de reglas firmados envían solicitudes HTTPS estándar sin tráfico ni configuración de paquetes. Cada una tiene su propio interruptor.

@@ -571,7 +572,7 @@

Ten claro qué sale de tu Mac.

diff --git a/docs/es/privacy/index.html b/docs/es/privacy/index.html index bbe7d615..7d62b059 100644 --- a/docs/es/privacy/index.html +++ b/docs/es/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -125,6 +125,7 @@

Peticiones de red que hace la app

PeticiónEnviada aQué contieneCuándo Comprobación de actualizacionesapi.github.comUna petición HTTPS normal de la última versión; GitHub ve tu dirección IP y el agente de usuario Flowlight/<version>Al arrancar y a diario, desde la 0.1.2. Interruptor para desactivarla en Ajustes › General Descarga de la actualizacióngithub.comLa imagen de disco y su archivo de suma de verificaciónSolo cuando pulsas Descargar y abrir + Actualización oficial del paquete de reglasflowlight.xinbetween.comUna solicitud HTTPS estándar al feed oficial fijo. El servidor ve tu dirección IP, el agente de usuario Flowlight/<version> y los validadores condicionales de caché; no recibe tráfico, ajustes de paquetes ni ningún identificador del dispositivoDespués del inicio, cuando corresponda, y luego como máximo una vez al día mientras todo funcione correctamente. Activada de forma predeterminada; interruptor para desactivarla y opción manual Buscar ahora en Reglas › Paquetes DNS inversoEl servidor DNS que tengas configuradoUna consulta del nombre registrado de una dirección IP, cuando no se ha visto ningún nombre de hostSegún haga falta, en el modo de captura del muestreador, que es el predeterminado hasta que la extensión de red esté en marcha. Sin interruptor para desactivarla: sin ella el muestreador no puede nombrar la mayoría de las conexiones. La extensión de red las nombra a partir del propio tráfico y no hace esta consulta Consulta del propietario de la redTeam Cymru, por DNSDirecciones IP públicas sin nombre de host conocido. Las direcciones privadas y reservadas no salen nunca de tu MacActivada por defecto. Interruptor para desactivarla en Captura › Identificar a los propietarios de la red Preguntar con un modelo alojadoEl proveedor que elijas: Anthropic, OpenAI, Google Gemini o un endpoint compatibleTu pregunta y los resultados que devuelvan las consultas de solo lectura de Flowlight que elija el modelo. La base de datos de tráfico en sí no se subeSolo cuando eliges un proveedor alojado y envías una pregunta. La pantalla Preguntar muestra las peticiones transmitidas @@ -188,7 +189,7 @@

Contacto

diff --git a/docs/es/threat-model/index.html b/docs/es/threat-model/index.html index d10fb6d9..1f1e7f54 100644 --- a/docs/es/threat-model/index.html +++ b/docs/es/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -152,36 +152,34 @@

Lo que la inspección se pierde

cosas se aplica, app por app.

Qué es bloquear, y qué no

-

De una regla que rechaza conexiones se encarga la extensión de red, en el camino, antes de que la conexión se haga. De una regla que nombra - una URL se encarga en cambio el proxy de inspección, que responde a la petición con un estado que la app puede leer. Esas son las dos únicas - partes de Flowlight que actúan en lugar de informar — el muestreador nettop no puede rechazar nada en absoluto, así que en un Mac que captura - con el muestreador no se aplica ninguna regla, y Flowlight lo dice en lugar de ofrecer un interruptor que no hace nada.

+

De una regla que rechaza conexiones se encarga normalmente la extensión de red, en el camino, antes de que la conexión se haga. Para el tráfico + encaminado por la inspección HTTPS, el proxy local aplica las reglas de destino y los paquetes gestionados activados al cliente original antes + de abrir la conexión con el servidor de origen; una regla que nombra una URL también se aplica allí, después del descifrado, y responde con un + estado que la app puede leer. Esas son las dos únicas partes de Flowlight que actúan en lugar de informar — el muestreador nettop no puede + rechazar nada en absoluto, así que en un Mac que captura con el muestreador no se aplica ninguna regla, y Flowlight lo dice en lugar de ofrecer + un interruptor que no hace nada.

Aun así no es impedir. Un agente que puede ejecutar un shell puede hacer a mano lo que habría hecho la herramienta rechazada. Un rechazo que puedes deshacer con un clic es un rechazo por el que el siguiente intento del agente puede pasar. El tráfico local, el DNS, los servicios de Apple y las propias conexiones de Flowlight no se rechazan nunca, porque un Mac bloqueado es peor resultado que un agente bloqueado. Y la aplicación de las reglas caduca unos segundos después de que Flowlight deje de ejecutarse: un rechazo que nadie puede ver es peor que ninguno.

-

La inspección y el rechazo no se combinan. El tráfico encaminado por el propio proxy de Flowlight sale del Mac como tráfico - de Flowlight, y Flowlight no se rechaza nunca a sí mismo — así que una app cuyas peticiones estás leyendo es una app cuyas conexiones las - reglas ya no detienen. Activar la inspección para ver más te cuesta la aplicación de las reglas, y hasta ahora nada en la pantalla lo decía.

+

La inspección conserva el rechazo por nombre de host, con un límite. El tráfico encaminado por el proxy de Flowlight sale del + Mac como tráfico exento de Flowlight, así que el proxy decide primero las reglas de destino normales y los paquetes gestionados activados usando + la identidad original de la app. Puede hacerlo incluso con túneles que fijan certificados o que nunca se descifran, porque CONNECT nombra el host. + Las listas de permitidos de agentes siguen siendo una decisión de la Extensión de red y no se aplican a ese tramo hacia el servidor de origen a + través del proxy; el tráfico sin un propietario fiable o sin nombre de host se permite en vez de rechazarse sin que nadie lo vea.

Lo que guarda Flowlight mismo

Metadatos de tráfico en una base de datos SQLite local, con una retención publicada. Con la inspección HTTPS activada guarda además los cuerpos de las peticiones y las respuestas durante tres días: lo más delicado que llega a conservar. Las cabeceras con credenciales se censuran por nombre y por palabra, lo cual es una heurística y no una garantía; una cabecera que lleve un secreto con un nombre que nada reconoce puede quedar registrada.

-

La base de datos de tráfico nunca se sube. Tres cosas más pequeñas sí salen del Mac sin que nadie las pida, las tres nombradas en la - página de privacidad: la comprobación de actualizaciones, que pide a GitHub la última versión al arrancar y a - diario; la consulta del propietario de la red, que pregunta a Team Cymru por direcciones públicas que no tienen nombre de host; y, mientras - Flowlight lee contadores en vez de ejecutar su extensión, una consulta de DNS inverso de las direcciones para las que no vio ningún nombre. - Las dos primeras se pueden desactivar. La tercera no, porque en el muestreador es casi la única manera en la que una conexión llega a tener - nombre: va al servidor DNS que el Mac ya usa, y se detiene en cuanto la extensión toma el relevo y nombra las conexiones a partir del propio - tráfico. Preguntar con un modelo alojado envía mucho más que cualquiera de ellas, y es lo único que está desactivado hasta que lo - configuras.

+

La base de datos de tráfico nunca se sube. La página de privacidad enumera cada petición saliente: comprobaciones de versión y de paquetes de reglas firmados, la consulta opcional del propietario de red y DNS inverso en el muestreador. Las tres primeras tienen interruptores; el DNS inverso no, porque sin él el muestreador no podría nombrar la mayoría de las conexiones. Preguntar con un modelo alojado envía mucho más y permanece desactivado hasta que lo configuras.

Actualizaciones

Las versiones se firman con un Developer ID y se notarizan. El actualizador exige una suma de comprobación publicada y rechaza una descarga que no la tenga; después comprueba que la app descargada esté firmada por el mismo equipo que la copia en ejecución antes de instalarla. Una actualización firmada por otra persona se rechaza aunque por lo demás sea válida.

+

Los paquetes de reglas oficiales usan una firma independiente limitada a este fin y una única fuente HTTPS fija. La aplicación mantiene por separado una marca de la secuencia más alta, vuelve a verificar al iniciarse los bytes exactos almacenados en caché, rechaza las regresiones y las publicaciones contradictorias, y nunca considera la firma del editor como permiso para aplicar contenido modificado. Los paquetes nuevos o modificados requieren la revisión explícita del contenido exacto y Guardar; el contenido obsoleto ya aceptado se etiqueta como la última versión válida conocida, mientras que la revocación de la confianza o los bytes dañados provocan un rechazo seguro.

Informar de una vulnerabilidad

Usa el informe privado de vulnerabilidades de GitHub, o escribe a @@ -223,7 +221,7 @@

Informar de una vulnerabilidad

diff --git a/docs/fr/about/index.html b/docs/fr/about/index.html index acae8664..6020cd64 100644 --- a/docs/fr/about/index.html +++ b/docs/fr/about/index.html @@ -152,7 +152,7 @@

Remerciements

diff --git a/docs/fr/docs/index.html b/docs/fr/docs/index.html index 58f48d2b..2bb34588 100644 --- a/docs/fr/docs/index.html +++ b/docs/fr/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

Documentation

Utiliser Flowlight

-

Tout, du premier lancement au réglage des règles des agents. Flowlight 0.15.3, macOS 15 ou version ultérieure.

+

Tout, du premier lancement au réglage des règles des agents. Flowlight 0.15.4, macOS 15 ou version ultérieure.

diff --git a/docs/fr/index.html b/docs/fr/index.html index ae22ced8..a013e9fb 100644 --- a/docs/fr/index.html +++ b/docs/fr/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

Voyez l’activité réseau de vos apps.

brew install --cask xinbetween/tap/flowlight

-

v0.15.3macOS 15+UniverselGPL-3.0Sans télémétrie

+

v0.15.4macOS 15+UniverselGPL-3.0Sans télémétrie

@@ -236,6 +236,7 @@

Voyez comment les agents IA se servent du réseau

une barrière contre ceux auxquels vous n’en faites pas — voyez le modèle de menaces.

Cursor ⇥ raw.githubusercontent.com · refusé 4 fois · en semaine 18:00–09:00

+
⛔ Sur activation

Packs gérés de publicité et de traqueurs

Trois packs officiels sont intégrés, visibles et désactivés par défaut. Flowlight consulte un flux HTTPS fixe unique pour rechercher les mises à jour signées ; tout contenu nouveau ou modifié reste inappliqué jusqu’à ce que vous examiniez cette version exacte et appuyiez sur Enregistrer. Pendant une panne, le contenu vérifié et accepté peut être conservé comme dernière version valide connue, visiblement signalée comme obsolète.

Ils bloquent par nom d’hôte et ne peuvent donc pas supprimer les publicités internes au site, les vides dans le DOM ou la mise en page, les superpositions du navigateur, les fenêtres contextuelles générées par des scripts ni les fenêtres d’applications natives.

⛔ Refuse

Des garde-fous sur les outils d’un agent

@@ -435,7 +436,7 @@

Un moniteur que vous n’avez pas à surveiller

Reste sur votre Mac

Un seul fichier SQLite local. Pas de compte, pas de synchronisation cloud, pas de télémétrie.

Des métadonnées par défaut

Noms d’hôte, ports et nombre d’octets. Déchiffrer le HTTPS est un mode distinct que vous devez activer, et qui reste sur votre Mac.

Filtré dans le noyau

La capture ne laisse passer que les réponses DNS et les ClientHello TLS. Tout le reste est écarté avant que Flowlight le voie.

-
Des recherches que vous maîtrisez

Nommer le propriétaire d’une adresse n’envoie à Team Cymru que des IP publiques sans nom d’hôte, et se désactive en un clic. La recherche quotidienne de mise à jour ne demande à GitHub que la dernière version, et peut aussi être désactivée.

+
Des recherches que vous maîtrisez

Nommer le propriétaire d’une adresse n’envoie à Team Cymru que les IP publiques sans nom d’hôte. Les vérifications de version et de packs de règles signés envoient des requêtes HTTPS standard sans trafic ni réglages de packs. Chacune dispose de son propre interrupteur.

@@ -571,7 +572,7 @@

Sachez ce qui quitte votre Mac.

diff --git a/docs/fr/privacy/index.html b/docs/fr/privacy/index.html index 3829d0ee..642647a8 100644 --- a/docs/fr/privacy/index.html +++ b/docs/fr/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -125,6 +125,7 @@

Les requêtes réseau que fait l’app

RequêteEnvoyée àCe qu’elle contientQuand Recherche de mise à jourapi.github.comUne requête HTTPS standard pour la dernière version ; GitHub voit votre adresse IP et l’agent utilisateur Flowlight/<version>Au lancement et une fois par jour, depuis 0.1.2. Interrupteur d’arrêt dans Réglages › Général Téléchargement de mise à jourgithub.comL’image disque et son fichier de somme de contrôleSeulement quand vous cliquez sur Télécharger & ouvrir + Mise à jour du pack de règles officielflowlight.xinbetween.comUne requête HTTPS standard vers le flux officiel fixe. Le serveur voit votre adresse IP, l’agent utilisateur Flowlight/<version> et les validateurs conditionnels du cache ; il ne reçoit ni trafic, ni réglages des packs, ni identifiant d’appareilAprès le démarrage, lorsqu’une vérification est due, puis au maximum une fois par jour tant que le service fonctionne normalement. Activée par défaut ; interrupteur de désactivation et option manuelle Rechercher maintenant dans Règles › Packs DNS inverseLe résolveur DNS que vous avez configuréUne recherche du nom enregistré d’une adresse IP, quand aucun nom d’hôte n’a été observéAu besoin, dans le mode de capture par échantillonnage, qui est celui par défaut jusqu’à ce que l’extension réseau tourne. Pas d’interrupteur d’arrêt : sans elle, l’échantillonneur ne peut pas nommer la plupart des connexions. L’extension réseau les nomme à partir du trafic lui-même et ne fait pas cette recherche Recherche du propriétaire de réseauTeam Cymru, par DNSDes adresses IP publiques sans nom d’hôte connu. Les adresses privées et réservées ne quittent jamais votre MacActivé par défaut. Interrupteur d’arrêt dans Capture › Identifier les propriétaires de réseau Demander avec un modèle hébergéLe fournisseur que vous choisissez : Anthropic, OpenAI, Google Gemini ou un point de terminaison compatibleVotre question et les résultats renvoyés par les requêtes Flowlight en lecture seule choisies par le modèle. La base de données de trafic elle-même n’est pas envoyéeSeulement quand vous choisissez un fournisseur hébergé et soumettez une question. L’écran Demander montre les requêtes transmises @@ -188,7 +189,7 @@

Contact

diff --git a/docs/fr/threat-model/index.html b/docs/fr/threat-model/index.html index a7b6e757..69add445 100644 --- a/docs/fr/threat-model/index.html +++ b/docs/fr/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -155,36 +155,34 @@

Ce que l’inspection manque

des deux s’applique, app par app.

Ce que le blocage est, et n’est pas

-

Une règle qui refuse des connexions est exécutée par l’extension réseau, sur le chemin, avant que la connexion ne soit faite. Une règle qui - nomme une URL est exécutée par le proxy d’inspection, qui répond à la requête avec un statut que l’app peut lire. Ce sont les deux seules - parties de Flowlight qui agissent au lieu de rapporter — l’échantillonneur nettop, lui, ne peut rien refuser du tout : sur un Mac qui - capture avec l’échantillonneur, aucune règle n’est appliquée, et Flowlight le dit plutôt que de proposer un interrupteur qui ne fait rien.

+

Une règle qui refuse des connexions est normalement exécutée par l’extension réseau, sur le chemin, avant que la connexion ne soit faite. Pour + le trafic acheminé par l’inspection HTTPS, le proxy local applique les règles de destination et les packs gérés activés au client d’origine + avant d’ouvrir la connexion au serveur d’origine ; une règle qui nomme une URL y est également exécutée après le déchiffrement, avec un + statut que l’app peut lire. Ce sont les deux seules parties de Flowlight qui agissent au lieu de rapporter — l’échantillonneur nettop, lui, ne + peut rien refuser du tout : sur un Mac qui capture avec l’échantillonneur, aucune règle n’est appliquée, et Flowlight le dit plutôt que de + proposer un interrupteur qui ne fait rien.

Ce n’est pourtant toujours pas empêcher. Un agent qui peut lancer un shell peut faire à la main ce que l’outil refusé aurait fait. Un refus que vous pouvez annuler d’un clic est un refus que la tentative suivante d’un agent peut franchir. Le trafic local, le DNS, les services d’Apple et les connexions de Flowlight lui-même ne sont jamais refusés, parce qu’un Mac bloqué est un pire résultat qu’un agent bloqué. Et l’application des refus cesse quelques secondes après l’arrêt de Flowlight : un refus que personne ne peut voir est pire que pas de refus.

-

Inspection et refus ne se combinent pas. Le trafic passé par le proxy de Flowlight lui-même quitte le Mac comme du trafic de - Flowlight, et Flowlight ne se refuse jamais lui-même — une app dont vous lisez les requêtes est donc une app dont les règles n’arrêtent plus - les connexions. Activer l’inspection pour en voir plus vous coûte l’application des refus, et rien à l’écran ne le disait jusqu’ici.

+

L’inspection préserve le refus par nom d’hôte, avec une limite. Le trafic acheminé par le proxy de Flowlight quitte le Mac comme + du trafic Flowlight exempté ; le proxy décide donc d’abord des règles de destination ordinaires et des packs gérés activés en utilisant + l’identité d’origine de l’app. Il peut le faire même pour les tunnels épinglés ou jamais déchiffrés, car CONNECT nomme l’hôte. Les listes + d’autorisation des agents restent une décision de l’Extension réseau et ne s’appliquent pas à ce trajet vers le serveur d’origine par le proxy ; + le trafic sans propriétaire fiable ou sans nom d’hôte est autorisé plutôt que refusé sans que personne ne le voie.

Ce que Flowlight lui-même conserve

Des métadonnées de trafic dans une base de données SQLite locale, avec une durée de conservation publiée. Avec l’inspection HTTPS activée, il conserve aussi les corps des requêtes et des réponses pendant trois jours — la chose la plus sensible qu’il garde jamais. Les en-têtes d’identification sont masqués par nom et par mot, ce qui est une heuristique et non une garantie ; un en-tête qui porte un secret sous un nom que rien ne reconnaît peut être enregistré.

-

La base de données de trafic n’est jamais téléversée. Trois choses plus petites quittent bien le Mac sans qu’on les demande, toutes trois - nommées sur la page Confidentialité : la recherche de mise à jour, qui demande à GitHub la dernière version - au lancement et une fois par jour ; la consultation du propriétaire du réseau, qui interroge Team Cymru sur les adresses publiques sans - nom d’hôte ; et, tant que Flowlight lit des compteurs au lieu de faire tourner son extension, une recherche DNS inverse des adresses dont - il n’a vu aucun nom. Les deux premières ont un interrupteur d’arrêt. La troisième non, parce que sur l’échantillonneur c’est l’essentiel de la - façon dont une connexion obtient un nom : elle va au résolveur que le Mac utilise déjà, et elle s’arrête dès que l’extension prend le - relais et nomme les connexions à partir du trafic lui-même. Demander avec un modèle hébergé envoie bien plus que n’importe laquelle d’entre - elles, et c’est la seule chose qui reste désactivée jusqu’à ce que vous la configuriez.

+

La base de données de trafic n’est jamais téléversée. La page Confidentialité répertorie chaque requête sortante : vérifications de version et de packs de règles signés, consultation facultative du propriétaire du réseau et DNS inverse avec l’échantillonneur. Les trois premières ont des interrupteurs ; le DNS inverse non, car sans lui l’échantillonneur ne pourrait pas nommer la plupart des connexions. Un modèle hébergé envoie bien plus et reste désactivé jusqu’à configuration.

Mises à jour

Les versions sont signées avec un Developer ID et notarisées. Le programme de mise à jour exige une somme de contrôle publiée et refuse un téléchargement qui n’en a pas, puis vérifie que l’app téléchargée est signée par la même équipe que la copie en cours d’exécution avant de l’installer. Une mise à jour signée par quelqu’un d’autre est refusée, même si elle est valide par ailleurs.

+

Les packs de règles officiels utilisent une signature distincte limitée à cet usage et une source HTTPS fixe unique. L’application conserve séparément un repère de la séquence la plus élevée, revérifie au démarrage les octets exacts mis en cache, rejette les retours en arrière et les publications contradictoires, et ne considère jamais la signature d’un éditeur comme une autorisation d’appliquer un contenu modifié. Les packs nouveaux ou modifiés exigent l’examen explicite du contenu exact et Enregistrer ; le contenu obsolète déjà accepté est étiqueté comme dernière version valide connue, tandis qu’une révocation de confiance ou des octets corrompus entraînent un refus sécurisé.

Signaler une vulnérabilité

Utilisez le signalement privé de vulnérabilités de GitHub, ou écrivez à @@ -226,7 +224,7 @@

Signaler une vulnérabilité

diff --git a/docs/index.html b/docs/index.html index 7388f166..c8b7ce4f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

See your apps' network activity.
brew install --cask xinbetween/tap/flowlight

-

v0.15.3macOS 15+UniversalGPL-3.0No telemetry

+

v0.15.4macOS 15+UniversalGPL-3.0No telemetry

@@ -248,7 +248,9 @@

Monitor how AI agents use the network

⛔ Opt-in

Managed ad and tracker packs

Three official packs — Advertising, Cross-site tracking, and Privacy and telemetry — are built in, visible and off by - default. With the Network Extension, apply one to every app or only selected apps; preview it against retained traffic, + default. Flowlight checks one fixed HTTPS feed for signed updates; new or changed content remains out of enforcement until + you review that exact version and press Save. During an outage, verified accepted content is visibly stale last-known-good.

+

With the Network Extension, apply one to every app or only selected apps; preview it against retained traffic, see grouped, explainable activity, and add pack-specific exceptions for an app. Ordinary allow rules remain the universal escape hatch, global Pause Blocking applies, and essential system traffic stays allowed.

They block by hostname, so they cannot remove first-party ads, DOM or layout gaps, browser overlays, scripted popups, or @@ -455,7 +457,7 @@

A monitor you don't have to monitor

Stays on your Mac

One local SQLite file. No account, no cloud sync, no telemetry.

Metadata by default

Hostnames, ports and byte counts. Decrypting HTTPS is a separate mode you have to turn on, and it stays on your Mac.

Filtered in the kernel

Capture passes only DNS answers and TLS ClientHellos. Everything else is dropped before Flowlight sees it.

-
Lookups you control

Naming who owns an address sends only public IPs with no hostname to Team Cymru, and turns off in one click. The daily update check only asks GitHub for the latest version, and can be turned off too.

+
Lookups you control

Naming who owns an address sends only public IPs with no hostname to Team Cymru. Release and signed rule-pack checks send standard HTTPS requests with no traffic or pack settings. Each has an off switch.

@@ -592,7 +594,7 @@

Know what leaves your Mac.

diff --git a/docs/it/about/index.html b/docs/it/about/index.html index 3a0616b8..9f874a76 100644 --- a/docs/it/about/index.html +++ b/docs/it/about/index.html @@ -152,7 +152,7 @@

Ringraziamenti

diff --git a/docs/it/docs/index.html b/docs/it/docs/index.html index 0c7e63b3..5791a9dc 100644 --- a/docs/it/docs/index.html +++ b/docs/it/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

Documentazione

Usare Flowlight

-

Tutto, dal primo avvio alla messa a punto delle regole per gli agenti. Flowlight 0.15.3, macOS 15 o successivo.

+

Tutto, dal primo avvio alla messa a punto delle regole per gli agenti. Flowlight 0.15.4, macOS 15 o successivo.

diff --git a/docs/it/index.html b/docs/it/index.html index d52a585a..253af041 100644 --- a/docs/it/index.html +++ b/docs/it/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

Vedi la rete delle tue app.
Ca Metti una stella su GitHub

brew install --cask xinbetween/tap/flowlight

-

v0.15.3macOS 15+UniversaleGPL-3.0Nessuna telemetria

+

v0.15.4macOS 15+UniversaleGPL-3.0Nessuna telemetria

@@ -236,6 +236,7 @@

Controlla come gli agenti IA usano la rete

software di cui non ti fidi — vedi il modello di minaccia.

Cursor ⇥ raw.githubusercontent.com · rifiutata 4 volte · nei giorni feriali 18:00–09:00

+
⛔ Da attivare

Pacchetti gestiti per pubblicità e tracker

Tre pacchetti ufficiali sono integrati, visibili e disattivati per impostazione predefinita. Flowlight controlla un unico feed HTTPS fisso per gli aggiornamenti firmati; i contenuti nuovi o modificati restano esclusi dall’applicazione finché non esamini quella versione esatta e premi Salva. Durante un’interruzione, i contenuti verificati e accettati possono restare disponibili come ultima versione valida nota, visibilmente indicata come obsoleta.

Bloccano in base al nome host, quindi non possono rimuovere pubblicità proprietarie, spazi vuoti nel DOM o nel layout, sovrapposizioni del browser, popup generati da script o finestre native delle app.

⛔ Rifiuta

Protezioni sugli strumenti di un agente

@@ -435,7 +436,7 @@

Un monitoraggio che non devi monitorare

Resta sul tuo Mac

Un solo file SQLite locale. Nessun account, nessuna sincronizzazione nel cloud, nessuna telemetria.

Metadati, per impostazione predefinita

Nomi host, porte e byte conteggiati. Decifrare HTTPS è una modalità separata che devi attivare tu, e resta sul tuo Mac.

Filtrato nel kernel

L'acquisizione lascia passare solo le risposte DNS e i ClientHello TLS. Tutto il resto viene scartato prima che Flowlight lo veda.

-
Ricerche che controlli tu

Per dare un nome al proprietario di un indirizzo vengono inviati a Team Cymru solo gli IP pubblici senza nome host, e si disattiva con un clic. Il controllo giornaliero degli aggiornamenti chiede a GitHub solo l'ultima versione, e anche quello si può disattivare.

+
Ricerche che controlli tu

Per dare un nome al proprietario di un indirizzo vengono inviati a Team Cymru solo gli IP pubblici senza nome host. I controlli della versione e dei pacchetti di regole firmati inviano richieste HTTPS standard senza traffico né impostazioni dei pacchetti. Ciascuno ha il proprio interruttore.

@@ -571,7 +572,7 @@

Sappi che cosa esce dal tuo Mac.

diff --git a/docs/it/privacy/index.html b/docs/it/privacy/index.html index adf1c035..ffb0cc18 100644 --- a/docs/it/privacy/index.html +++ b/docs/it/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -125,6 +125,7 @@

Richieste di rete che fa l'app

RichiestaInviata aCosa contieneQuando Controllo aggiornamentiapi.github.comUna normale richiesta HTTPS per l'ultima versione; GitHub vede il tuo indirizzo IP e lo user agent Flowlight/<version>All'avvio e una volta al giorno, dalla 0.1.2. Interruttore per disattivarla in Impostazioni › Generali Download dell'aggiornamentogithub.comL'immagine disco e il suo file di checksumSolo quando fai clic su Scarica e apri + Aggiornamento ufficiale del pacchetto di regoleflowlight.xinbetween.comUna richiesta HTTPS standard al feed ufficiale fisso. Il server vede il tuo indirizzo IP, lo user agent Flowlight/<version> e i validatori condizionali della cache; non riceve traffico, impostazioni dei pacchetti o identificatori del dispositivoDopo l’avvio, quando previsto, e successivamente non più di una volta al giorno durante il normale funzionamento. Attivo per impostazione predefinita; interruttore per disattivarlo e opzione manuale Controlla ora in Regole › Pacchetti DNS inversoIl resolver DNS che hai configuratoUna ricerca del nome registrato di un indirizzo IP, quando non è stato rilevato alcun nome hostQuando serve, nella modalità di acquisizione a campionamento, che è quella predefinita finché l'estensione di rete non è in funzione. Nessun interruttore per disattivarla: senza di essa il campionatore non può dare un nome alla maggior parte delle connessioni. L'estensione di rete le nomina dal traffico stesso e non fa questa ricerca Ricerca del proprietario della reteTeam Cymru, via DNSIndirizzi IP pubblici senza nome host noto. Gli indirizzi privati e riservati non lasciano mai il tuo MacAttiva per impostazione predefinita. Interruttore per disattivarla in Acquisizione › Identifica i proprietari della rete Chiedi con un modello ospitatoIl provider che selezioni: Anthropic, OpenAI, Google Gemini o un endpoint compatibileLa tua domanda e i risultati restituiti dalle query di sola lettura di Flowlight selezionate dal modello. Il database del traffico non viene caricatoSolo quando selezioni un provider ospitato e invii una domanda. La schermata Chiedi mostra le richieste trasmesse @@ -188,7 +189,7 @@

Contatti

diff --git a/docs/it/threat-model/index.html b/docs/it/threat-model/index.html index aad21923..2bb204f9 100644 --- a/docs/it/threat-model/index.html +++ b/docs/it/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -154,35 +154,34 @@

Cosa sfugge all'ispezione

cose valga, app per app.

Cosa è, e cosa non è, il blocco

-

Una regola che rifiuta le connessioni viene eseguita dall'estensione di rete, sul percorso, prima che la connessione venga stabilita. Una - regola che nomina un URL viene eseguita invece dal proxy di ispezione, che risponde alla richiesta con uno stato che l'app può leggere. Sono le - sole due parti di Flowlight che agiscono invece di riferire — il campionatore nettop non può rifiutare proprio nulla, quindi su un Mac che - acquisisce con il campionatore nessuna regola viene applicata, e Flowlight lo dice invece di offrire un interruttore che non fa niente.

+

Una regola che rifiuta le connessioni viene normalmente eseguita dall'estensione di rete, sul percorso, prima che la connessione venga stabilita. + Per il traffico instradato attraverso l'ispezione HTTPS, il proxy locale applica le regole di destinazione e i pacchetti gestiti attivi al client + originale prima di aprire la connessione al server di origine; una regola che nomina un URL viene eseguita anche lì, dopo la decifratura, con uno + stato che l'app può leggere. Sono le sole due parti di Flowlight che agiscono invece di riferire — il campionatore nettop non può rifiutare + proprio nulla, quindi su un Mac che acquisisce con il campionatore nessuna regola viene applicata, e Flowlight lo dice invece di offrire un + interruttore che non fa niente.

Non è comunque impedire. Un agente che può eseguire una shell può fare a mano ciò che avrebbe fatto lo strumento rifiutato. Un rifiuto che puoi annullare con un clic è un rifiuto attraverso cui il tentativo successivo di un agente può passare. Il traffico locale, il DNS, i servizi di Apple e le connessioni di Flowlight stesso non vengono mai rifiutati, perché un Mac bloccato è un esito peggiore di un agente bloccato. E l'applicazione decade pochi secondi dopo che Flowlight smette di funzionare: un rifiuto che nessuno può vedere è peggio di nessun rifiuto.

-

Ispezione e rifiuto non si combinano. Il traffico instradato attraverso il proxy di Flowlight stesso lascia il Mac come - traffico di Flowlight, e Flowlight non rifiuta mai se stesso — quindi un'app di cui stai leggendo le richieste è un'app le cui connessioni le - regole non fermano più. Attivare l'ispezione per vedere di più ti costa l'applicazione delle regole, e finora nulla sullo schermo lo diceva.

+

L'ispezione mantiene il rifiuto per nome host, con un limite. Il traffico instradato attraverso il proxy di Flowlight lascia il + Mac come traffico Flowlight esente, quindi il proxy decide prima le normali regole di destinazione e i pacchetti gestiti attivi usando l'identità + originale dell'app. Può farlo anche per tunnel con pinning o mai decifrati, perché CONNECT specifica l'host. Gli elenchi consentiti degli agenti + restano una decisione dell'Estensione di rete e non si applicano a quel tratto verso il server di origine attraverso il proxy; il traffico senza + un proprietario attendibile o senza nome host viene consentito invece di essere rifiutato senza che nessuno lo veda.

Cosa conserva Flowlight stesso

Metadati del traffico in un database SQLite locale, con una conservazione pubblicata. Con l'ispezione HTTPS attiva conserva anche i corpi delle richieste e delle risposte per tre giorni — la cosa più delicata che arrivi mai a tenere. Le intestazioni con credenziali vengono oscurate per nome e per parola, che è un'euristica e non una garanzia; un'intestazione che porta un segreto sotto un nome che nulla riconosce può essere registrata.

-

Il database del traffico non viene mai caricato. Tre cose più piccole lasciano il Mac senza che nessuno le chieda, tutte e tre nominate nella - pagina sulla privacy: il controllo degli aggiornamenti, che chiede a GitHub l'ultima versione all'avvio e una - volta al giorno; la ricerca del proprietario della rete, che chiede a Team Cymru degli indirizzi pubblici senza nome host; e, finché Flowlight - legge i contatori invece di far girare la sua estensione, una ricerca DNS inversa degli indirizzi per cui non ha visto alcun nome. Le prime due - hanno un interruttore per disattivarle. La terza no, perché sul campionatore è in gran parte il modo in cui una connessione arriva ad avere un - nome: va al resolver che il Mac già usa, e si ferma appena l'estensione prende il suo posto e nomina le connessioni dal traffico stesso. Chiedi - con un modello ospitato invia molto più di ognuna di esse, ed è la sola cosa che resta disattivata finché non la configuri.

+

Il database del traffico non viene mai caricato. La pagina sulla privacy elenca ogni richiesta in uscita: controlli della versione e dei pacchetti di regole firmati, ricerca facoltativa del proprietario della rete e DNS inverso con il campionatore. Le prime tre hanno interruttori; il DNS inverso no, perché senza di esso il campionatore non potrebbe nominare la maggior parte delle connessioni. Un modello ospitato invia molto di più e resta disattivato finché non lo configuri.

Aggiornamenti

Le versioni sono firmate con un Developer ID e notarizzate. L'aggiornamento richiede una checksum pubblicata e rifiuta un download che non ne ha una, poi verifica che l'app scaricata sia firmata dallo stesso team della copia in esecuzione prima di installarla. Un aggiornamento firmato da qualcun altro viene rifiutato anche se per il resto è valido.

+

I pacchetti di regole ufficiali usano una firma distinta e limitata a questo scopo e un’unica fonte HTTPS fissa. L’app conserva separatamente un marcatore della sequenza più alta, verifica di nuovo all’avvio gli esatti byte memorizzati nella cache, rifiuta rollback e pubblicazioni discordanti e non considera mai la firma dell’editore un’autorizzazione ad applicare contenuti modificati. I pacchetti nuovi o modificati richiedono l’esame esplicito del contenuto esatto e Salva; i contenuti obsoleti già accettati sono indicati come ultima versione valida nota, mentre la revoca dell’attendibilità o byte danneggiati fanno rifiutare il contenuto per sicurezza.

Segnalare una vulnerabilità

Usa la segnalazione privata delle vulnerabilità di GitHub, oppure scrivi a @@ -224,7 +223,7 @@

Segnalare una vulnerabilità

diff --git a/docs/ja/about/index.html b/docs/ja/about/index.html index 1acfde99..ef2d0150 100644 --- a/docs/ja/about/index.html +++ b/docs/ja/about/index.html @@ -153,7 +153,7 @@

謝辞

diff --git a/docs/ja/docs/index.html b/docs/ja/docs/index.html index 5235c93f..f438ecc4 100644 --- a/docs/ja/docs/index.html +++ b/docs/ja/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

ドキュメント

Flowlight の使い方

-

初回起動からエージェントのルールの調整まで、すべてここに。Flowlight 0.15.3、macOS 15 以降。

+

初回起動からエージェントのルールの調整まで、すべてここに。Flowlight 0.15.4、macOS 15 以降。

diff --git a/docs/ja/index.html b/docs/ja/index.html index 65aa6668..7e3575f6 100644 --- a/docs/ja/index.html +++ b/docs/ja/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

アプリの通信が見える。
brew install --cask xinbetween/tap/flowlight

-

v0.15.3macOS 15+ユニバーサルGPL-3.0テレメトリなし

+

v0.15.4macOS 15+ユニバーサルGPL-3.0テレメトリなし

@@ -236,6 +236,7 @@

AI エージェントのネットワーク利用を見張る

信頼していないソフトウェアを阻む壁ではありません。脅威モデルをご覧ください。

Cursor ⇥ raw.githubusercontent.com · 4 回拒否 · 平日 18:00–09:00

+
⛔ オプトイン

管理対象の広告・トラッカーパック

3 つの公式パックが内蔵され、最初から表示されていますが、デフォルトではすべてオフです。Flowlight は署名付きアップデートがないか、1 つの固定 HTTPS フィードを確認します。新しいコンテンツや変更されたコンテンツは、その正確なバージョンを確認して保存を押すまで適用されません。障害中は、検証・受け入れ済みのコンテンツが、古くなっていることを明示した最後に正常と確認された版として保持される場合があります。

ホスト名単位でブロックするため、ファーストパーティ広告、DOM やレイアウトの空白、ブラウザのオーバーレイ、スクリプトによるポップアップ、ネイティブアプリのウインドウは除去できません。

⛔ 拒否

エージェントのツールにガードレール

@@ -434,7 +435,7 @@

監視しなくていい監視ツール

Mac の中だけ

ローカルの SQLite ファイル 1 つ。アカウントなし、クラウド同期なし、テレメトリなし。

標準はメタデータだけ

ホスト名、ポート、バイト数。HTTPS の復号は自分でオンにする別のモードで、これも Mac の中にとどまります。

カーネルで絞り込み

キャプチャが通すのは DNS の応答と TLS の ClientHello だけ。ほかは Flowlight が見る前に捨てられます。

-
問い合わせは自分で決める

アドレスの所有者を調べる機能が Team Cymru に送るのは、ホスト名のないパブリック IP だけで、ワンクリックでオフにできます。1 日 1 回のアップデート確認も、GitHub に最新バージョンを尋ねるだけで、こちらもオフにできます。

+
問い合わせは自分で決める

アドレスの所有者を調べる機能が Team Cymru に送るのは、ホスト名のないパブリック IP だけです。リリースと署名付きルールパックの確認は、トラフィックやパック設定を含まない標準 HTTPS リクエストを送ります。それぞれ個別にオフにできます。

@@ -570,7 +571,7 @@

Mac から何が出ていくのかを知る。

diff --git a/docs/ja/privacy/index.html b/docs/ja/privacy/index.html index 0cff65ff..556295b5 100644 --- a/docs/ja/privacy/index.html +++ b/docs/ja/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -123,6 +123,7 @@

アプリが行うネットワークリクエスト

リクエスト送信先含まれるものタイミング アップデートの確認api.github.com最新リリースを尋ねる通常の HTTPS リクエスト。GitHub が見るのは、IP アドレスと Flowlight/<version> というユーザーエージェントです起動時と 1 日 1 回(0.1.2 以降)。オフのスイッチは設定 › 一般 アップデートのダウンロードgithub.comディスクイメージと、そのチェックサムファイル「ダウンロードして開く」をクリックしたときだけ + 公式ルールパックのアップデートflowlight.xinbetween.com固定された公式フィードへの標準 HTTPS リクエストです。サーバには、IP アドレス、Flowlight/<version>ユーザーエージェント、条件付きキャッシュバリデータが表示されます。トラフィック、パック設定、デバイス識別子は送信されません起動後、確認期限に達している場合に実行し、その後は正常稼働中に1日最大1回実行します。デフォルトでオンです。ルール › パックでオフにするか、今すぐ確認を手動で実行できます 逆引き DNSあなたが設定した DNS リゾルバホスト名が見つからなかった場合の、IP アドレスの登録名の照会必要に応じて、サンプラーのキャプチャモードで。ネットワーク機能拡張が動き出すまではこれがデフォルトです。オフのスイッチはありません。これがないとサンプラーはほとんどの接続に名前を付けられないからです。ネットワーク機能拡張はトラフィック自身から名前を付けるので、これを行いません ネットワーク所有者の照会Team Cymru(DNS 経由)ホスト名がわからないパブリック IP アドレス。プライベートアドレスと予約アドレスは、この Mac から決して出ませんデフォルトでオン。オフのスイッチはキャプチャ › ネットワーク所有者を特定 ホスト型モデルでの質問あなたが選んだプロバイダ:Anthropic、OpenAI、Google Gemini、または互換のエンドポイントあなたの質問と、モデルが選んだ読み取り専用の Flowlight の問い合わせが返した結果。トラフィックのデータベース自体はアップロードされませんホスト型のプロバイダを選び、質問を送信したときだけ。質問の画面には、送信されたリクエストが表示されます @@ -186,7 +187,7 @@

連絡先

diff --git a/docs/ja/threat-model/index.html b/docs/ja/threat-model/index.html index 087477ed..cc34dcf5 100644 --- a/docs/ja/threat-model/index.html +++ b/docs/ja/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -154,36 +154,33 @@

インスペクションが取りこぼすもの

ありません。どちらなのかは、カバー範囲がアプリごとに答えます。

ブロックとは何で、何でないか

-

接続を拒否するルールを実行するのはネットワーク機能拡張で、その経路の中で、接続が行われる前に実行します。URL を指定する - ルールを実行するのはインスペクション用のプロキシで、アプリが読める状態コードでリクエストに応えます。報告するのではなく実際に - 手を出す Flowlight の部分は、この 2 つだけです。nettop サンプラーは何ひとつ拒否できないので、サンプラーで動いている Mac では - どのルールも強制されません。Flowlight は何もしないスイッチを見せるのではなく、そのことをはっきり言います。

+

接続を拒否するルールは通常、ネットワーク機能拡張がその経路の中で、接続が行われる前に実行します。HTTPS インスペクションを + 経由するトラフィックについては、ローカルプロキシが接続先の通常ルールと有効な管理対象パックを元のクライアントに対して適用してから、 + 接続先サーバへの接続を開きます。URL を指定するルールも復号後にそこで実行され、アプリが読める状態コードで応答します。報告する + のではなく実際に手を出す Flowlight の部分は、この 2 つだけです。nettop サンプラーは何ひとつ拒否できないので、サンプラーで + 動いている Mac ではどのルールも強制されません。Flowlight は何もしないスイッチを見せるのではなく、そのことをはっきり言います。

それでも防止ではありません。シェルを使えるエージェントは、拒否されたツールがしたはずのことを手作業で行えます。ワンクリックで 取り消せる拒否は、エージェントの次の試みが通り抜けうる拒否でもあります。ローカルのトラフィック、DNS、Apple 純正のサービス、 Flowlight 自身の接続は決して拒否されません。ブロックされたエージェントより、ブロックされた Mac のほうが悪い結果だからです。 そして強制は、Flowlight が動作を止めた数秒後に失効します。誰にも見えない拒否は、拒否がないより悪いからです。

-

インスペクションと拒否は両立しません。Flowlight 自身のプロキシを通ったトラフィックは、Flowlight の - トラフィックとして Mac を出ていき、Flowlight が自分自身を拒否することはありません。つまり、リクエストを読んでいるアプリは、 - ルールではもう接続を止められないアプリです。もっと見るためにインスペクションをオンにすることは、強制を手放すことでもあり、 - 画面にはこれまでそう書かれていませんでした。

+

インスペクションでもホスト名による拒否は保たれますが、境界があります。Flowlight のプロキシを通ったトラフィックは、 + 除外対象の Flowlight トラフィックとして Mac を出ていくため、プロキシがまず元のアプリの識別情報を使って、接続先の通常ルールと + 有効な管理対象パックを判定します。CONNECT がホスト名を示すので、証明書がピン留めされている、または復号されないトンネルでも + 判定できます。エージェントの許可リストはネットワーク機能拡張による判定のままで、プロキシから接続先サーバへ向かう区間には + 適用されません。信頼できる所有者またはホスト名がないトラフィックは、見えないところで拒否せず、接続を許可します。

Flowlight 自身が保持するもの

トラフィックのメタデータを、ローカルの SQLite データベースに、公開された保持期間のあいだ保持します。HTTPS インスペクションを オンにしていれば、リクエストとレスポンスの本文も 3 日間保持します。これは Flowlight が保持するもののなかで最も機微なものです。 認証情報のヘッダは名前と語によって隠されますが、それはヒューリスティックであって保証ではありません。何も認識できない名前のもとに 秘密を載せたヘッダは、記録されることがあります。

-

トラフィックのデータベースがアップロードされることはありません。それでも、頼まなくても Mac から出ていくものが 3 つあり、 - どれもプライバシーのページに挙げてあります。起動時と 1 日 1 回、最新のリリースを GitHub に - 尋ねるアップデートの確認。ホスト名のない公開アドレスについて Team Cymru に尋ねるネットワーク所有者の照会。そして、 - Flowlight が機能拡張ではなくカウンタを読んでいるあいだ、名前が見つからなかったアドレスの逆引き DNS 照会です。 - はじめの 2 つにはオフのスイッチがあります。3 つめにはありません。サンプラーでは、接続に名前が付く道のほとんどがそれだからです。 - 照会はこの Mac がすでに使っているリゾルバへ行き、機能拡張が引き継いでトラフィック自身から名前を付けるようになれば止まります。 - ホスト型モデルでの質問は、そのどれよりもはるかに多くを送りますが、設定するまではオフのままである唯一のものです。

+

トラフィックのデータベースがアップロードされることはありません。プライバシーのページには、リリースと署名付きルールパックの確認、任意のネットワーク所有者照会、サンプラーの逆引き DNS というすべての送信を記載しています。最初の 3 つにはオフのスイッチがあります。逆引き DNS は、多くの接続に名前を付けるため必要なのでありません。ホスト型モデルはより多くを送信し、設定するまでオフです。

アップデート

リリースは Developer ID で署名され、公証されています。アップデート機能は公開されたチェックサムを要求し、それがないダウンロードを 拒否します。そのうえで、ダウンロードしたアプリが実行中のコピーと同じチームの署名であることを確かめてから、インストールします。 別の誰かが署名したアップデートは、ほかの点で有効であっても拒否されます。

+

公式ルールパックは、用途を限定した独立の署名と、1 つの固定 HTTPS ソースを使用します。アプリは独立した最高シーケンス番号のウォーターマークを保持し、起動時にキャッシュされた正確なバイト列を再検証し、ロールバックと相互に矛盾する内容の提示を拒否します。また、公開元の署名を、変更されたコンテンツの適用を許可するものとして扱うことはありません。新しいパックや変更されたパックには、正確な内容の明示的な確認と保存が必要です。古くなった受け入れ済みコンテンツには、最後に正常と確認された版であることが表示されます。信頼が取り消された場合やバイト列が破損している場合は、安全側に倒して拒否します。

脆弱性を報告する

GitHub のプライベート脆弱性報告をお使いください。GitHub を @@ -225,7 +222,7 @@

脆弱性を報告する

diff --git a/docs/ko/about/index.html b/docs/ko/about/index.html index 7a083e42..b1086fb1 100644 --- a/docs/ko/about/index.html +++ b/docs/ko/about/index.html @@ -153,7 +153,7 @@

감사

diff --git a/docs/ko/docs/index.html b/docs/ko/docs/index.html index 488caa0b..c9c147f1 100644 --- a/docs/ko/docs/index.html +++ b/docs/ko/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

문서

Flowlight 사용하기

-

첫 실행부터 에이전트 규칙 조정까지 전부. Flowlight 0.15.3, macOS 15 이상.

+

첫 실행부터 에이전트 규칙 조정까지 전부. Flowlight 0.15.4, macOS 15 이상.

diff --git a/docs/ko/index.html b/docs/ko/index.html index 074fc8d5..0bd4cdb2 100644 --- a/docs/ko/index.html +++ b/docs/ko/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

앱이 무엇을 하는지 봅니다.

brew install --cask xinbetween/tap/flowlight

-

v0.15.3macOS 15+유니버설GPL-3.0텔레메트리 없음

+

v0.15.4macOS 15+유니버설GPL-3.0텔레메트리 없음

@@ -236,6 +236,7 @@

AI 에이전트가 네트워크를 어떻게 쓰는지 봅니다

소프트웨어를 막아 주는 장벽은 아닙니다. 위협 모델을 보세요.

Cursor ⇥ raw.githubusercontent.com · 4회 거부 · 평일 18:00–09:00

+
⛔ 직접 켜기

관리형 광고 및 추적기 팩

세 가지 공식 팩이 내장되어 있고 처음부터 표시되지만 모두 기본적으로 꺼져 있습니다. Flowlight는 서명된 업데이트가 있는지 하나의 고정 HTTPS 피드를 확인합니다. 새 콘텐츠나 변경된 콘텐츠는 정확한 해당 버전을 검토하고 저장을 누를 때까지 차단 적용에서 제외됩니다. 서비스 중단 중에는 검증되고 승인된 콘텐츠가 오래된 상태임을 명확히 표시한 마지막 정상 버전으로 유지될 수 있습니다.

호스트 이름을 기준으로 차단하므로 자사 광고, DOM이나 레이아웃의 빈 공간, 브라우저 오버레이, 스크립트로 생성된 팝업 또는 네이티브 앱 창은 제거할 수 없습니다.

⛔ 거부

에이전트의 도구에 두는 가드레일

@@ -435,7 +436,7 @@

감시하지 않아도 되는 감시 도구

Mac 안에 머무릅니다

로컬 SQLite 파일 하나. 계정도, 클라우드 동기화도, 텔레메트리도 없습니다.

기본은 메타데이터

호스트 이름과 포트, 바이트 수. HTTPS 복호화는 직접 켜야 하는 별도 모드이며, 그 결과도 Mac 안에 남습니다.

커널에서 걸러냅니다

캡처는 DNS 응답과 TLS ClientHello만 통과시킵니다. 나머지는 Flowlight가 보기 전에 버려집니다.

-
조회는 내가 정합니다

주소의 소유자를 알아낼 때는 호스트 이름이 없는 공용 IP만 Team Cymru로 보내며, 클릭 한 번으로 끌 수 있습니다. 하루 한 번 업데이트 확인은 GitHub에 최신 버전만 물어보고, 이것도 끌 수 있습니다.

+
조회는 내가 정합니다

주소의 소유자를 알아낼 때는 호스트 이름이 없는 공용 IP만 Team Cymru로 보냅니다. 릴리스와 서명된 규칙 팩 확인은 트래픽이나 팩 설정 없이 표준 HTTPS 요청을 보냅니다. 각각 별도로 끌 수 있습니다.

@@ -571,7 +572,7 @@

내 Mac에서 무엇이 나가는지 아세요.

diff --git a/docs/ko/privacy/index.html b/docs/ko/privacy/index.html index b56899eb..ff38ea52 100644 --- a/docs/ko/privacy/index.html +++ b/docs/ko/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -126,6 +126,7 @@

앱이 내는 네트워크 요청

요청보내는 곳담기는 내용시점 업데이트 확인api.github.com최신 릴리스를 묻는 일반적인 HTTPS 요청. GitHub는 IP 주소와 Flowlight/<version> 사용자 에이전트를 보게 됩니다0.1.2부터, 실행 시와 하루에 한 번. 끄는 스위치는 설정 › 일반 업데이트 다운로드github.com디스크 이미지와 체크섬 파일다운로드 후 열기를 클릭할 때만 + 공식 규칙 팩 업데이트flowlight.xinbetween.com고정된 공식 피드로 보내는 표준 HTTPS 요청입니다. 서버에는 사용자의 IP 주소, Flowlight/<version> 사용자 에이전트 및 조건부 캐시 유효성 검사기가 표시되며, 트래픽, 팩 설정 또는 기기 식별자는 전송되지 않습니다시작 후 확인 시점이 되면 실행되고, 이후 정상 작동 중에는 하루에 최대 한 번 실행됩니다. 기본적으로 켜져 있으며, 규칙 › 팩에서 끄거나 지금 확인을 수동으로 실행할 수 있습니다 역방향 DNS설정된 DNS 리졸버호스트 이름이 관측되지 않았을 때, IP 주소에 등록된 이름 조회필요할 때, 샘플러 캡처 모드에서. 네트워크 확장이 돌기 시작할 때까지는 이것이 기본값입니다. 끄는 스위치는 없습니다. 이것이 없으면 샘플러는 대부분의 연결에 이름을 붙일 수 없습니다. 네트워크 확장은 트래픽 자체에서 이름을 붙이므로 이 조회를 하지 않습니다 네트워크 소유자 조회Team Cymru, DNS로알려진 호스트 이름이 없는 공용 IP 주소. 사설 주소와 예약 주소는 절대 Mac을 벗어나지 않습니다기본값 켬. 끄는 스위치는 캡처 › 네트워크 소유자 확인 호스팅 모델로 질문선택한 제공업체: Anthropic, OpenAI, Google Gemini 또는 호환 엔드포인트내 질문과, 모델이 고른 Flowlight 읽기 전용 질의가 돌려준 결과. 트래픽 데이터베이스 자체는 업로드되지 않습니다호스팅 제공업체를 고르고 질문을 보낼 때만. 질문 화면에 전송된 요청이 표시됩니다 @@ -189,7 +190,7 @@

문의

diff --git a/docs/ko/threat-model/index.html b/docs/ko/threat-model/index.html index 8ac96d4c..af9a0bcd 100644 --- a/docs/ko/threat-model/index.html +++ b/docs/ko/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -154,34 +154,32 @@

검사가 놓치는 것

어느 쪽인지는 포착 범위가 앱마다 알려줍니다.

차단이 무엇이고 무엇이 아닌지

-

연결을 거부하는 규칙을 실행하는 것은 네트워크 확장이며, 그 경로 안에서, 연결이 만들어지기 전에 실행합니다. URL을 지정하는 - 규칙은 검사 프록시가 대신 실행해, 앱이 읽을 수 있는 상태 코드로 요청에 답합니다. 보고하는 대신 실제로 손을 쓰는 Flowlight의 - 부분은 이 둘뿐입니다. nettop 샘플러는 아무것도 거부할 수 없으므로, 샘플러로 돌아가는 Mac에서는 어떤 규칙도 적용되지 않습니다. - Flowlight는 아무 일도 하지 않는 스위치를 내놓는 대신 그렇다고 말합니다.

+

연결을 거부하는 규칙은 보통 네트워크 확장이 그 경로 안에서, 연결이 만들어지기 전에 실행합니다. HTTPS 검사를 거치는 트래픽은 + 로컬 프록시가 원본 서버 연결을 열기 전에 원래 클라이언트에 대상 규칙과 활성화된 관리형 팩을 적용합니다. URL을 지정하는 규칙도 + 복호화 후 그곳에서 실행되어, 앱이 읽을 수 있는 상태 코드로 응답합니다. 보고하는 대신 실제로 손을 쓰는 Flowlight의 부분은 이 + 둘뿐입니다. nettop 샘플러는 아무것도 거부할 수 없으므로, 샘플러로 돌아가는 Mac에서는 어떤 규칙도 적용되지 않습니다. Flowlight는 + 아무 일도 하지 않는 스위치를 내놓는 대신 그렇다고 말합니다.

그래도 예방은 아닙니다. 셸을 쓸 수 있는 에이전트는 거부된 도구가 했을 일을 직접 할 수 있습니다. 클릭 한 번으로 되돌릴 수 있는 거부는, 에이전트의 다음 시도가 걸어 나갈 수 있는 거부입니다. 로컬 트래픽과 DNS, Apple 기본 서비스, Flowlight 자신의 연결은 절대 거부되지 않습니다. 막힌 Mac은 막힌 에이전트보다 나쁜 결과이기 때문입니다. 그리고 Flowlight가 멈추면 몇 초 뒤에 적용이 풀립니다. 아무도 볼 수 없는 거부는 거부가 없는 것보다 나쁘기 때문입니다.

-

검사와 거부는 함께 가지 않습니다. Flowlight 자신의 프록시를 거친 트래픽은 Flowlight의 트래픽으로 Mac을 - 떠나고, Flowlight는 자기 자신을 거부하지 않습니다. 그러니 요청을 읽고 있는 앱은, 규칙으로는 더 이상 연결을 막을 수 없는 - 앱입니다. 더 보려고 검사를 켜는 일은 적용을 내주는 일이기도 한데, 화면에는 지금까지 그런 말이 없었습니다.

+

검사에서도 호스트 이름에 따른 거부는 유지되지만 경계가 있습니다. Flowlight의 프록시를 거친 트래픽은 예외 처리된 + Flowlight 트래픽으로 Mac을 떠나므로, 프록시가 먼저 원래 앱의 신원을 사용해 일반 대상 규칙과 활성화된 관리형 팩을 결정합니다. + CONNECT가 호스트 이름을 지정하므로 인증서가 고정되었거나 복호화되지 않는 터널에서도 이 판정을 할 수 있습니다. 에이전트 허용 목록은 + 네트워크 확장의 결정으로 남으며 프록시에서 원본 서버로 가는 구간에는 적용되지 않습니다. 신뢰할 수 있는 소유자나 호스트 이름이 없는 + 트래픽은 보이지 않게 거부하지 않고 연결을 허용합니다.

Flowlight 자신이 갖고 있는 것

트래픽 메타데이터를 로컬 SQLite 데이터베이스에, 공개된 보관 기간만큼 갖고 있습니다. HTTPS 검사를 켜 두면 요청과 응답의 본문도 3일 동안 갖고 있는데, 이것이 Flowlight가 보관하는 것 중 가장 민감한 것입니다. 인증 정보 헤더는 이름과 낱말로 가려지지만, 그것은 추정 규칙이고 보장이 아닙니다. 아무것도 알아보지 못하는 이름 아래 비밀을 실어 보내는 헤더는 기록될 수 있습니다.

-

트래픽 데이터베이스는 업로드되지 않습니다. 그래도 묻지 않아도 Mac을 떠나는 것이 세 가지 있고, 셋 다 - 개인정보 페이지에 이름이 적혀 있습니다. 실행할 때와 하루에 한 번 GitHub에 최신 릴리스를 묻는 - 업데이트 확인, 호스트 이름이 없는 공개 주소를 Team Cymru에 묻는 네트워크 소유자 조회, 그리고 Flowlight가 확장 대신 카운터를 - 읽고 있는 동안 이름을 보지 못한 주소에 대한 역방향 DNS 조회입니다. 앞의 둘은 끄는 스위치가 있습니다. 셋째는 없습니다. - 샘플러에서는 연결에 이름이 붙는 길이 대부분 그것이기 때문입니다. 그 조회는 Mac이 이미 쓰는 리졸버로 가고, 확장이 이어받아 - 트래픽 자체에서 이름을 붙이기 시작하면 멈춥니다. 호스팅 모델로 하는 질문은 그 셋 가운데 어느 것보다도 훨씬 많은 것을 - 보내지만, 직접 설정할 때까지 꺼져 있는 유일한 것입니다.

+

트래픽 데이터베이스는 업로드되지 않습니다. 개인정보 페이지에는 릴리스와 서명된 규칙 팩 확인, 선택 사항인 네트워크 소유자 조회, 샘플러의 역방향 DNS 등 모든 외부 요청이 나열됩니다. 앞의 세 항목에는 끄기 스위치가 있습니다. 역방향 DNS는 대부분의 연결에 이름을 붙이는 데 필요해 스위치가 없습니다. 호스팅 모델은 훨씬 더 많은 정보를 보내며 설정하기 전에는 꺼져 있습니다.

업데이트

릴리스는 Developer ID로 서명되고 공증됩니다. 업데이터는 공개된 체크섬을 요구하고 그것이 없는 내려받기를 거부하며, 그다음 설치 전에 내려받은 앱이 실행 중인 사본과 같은 팀의 서명인지 확인합니다. 다른 사람이 서명한 업데이트는 나머지가 다 유효해도 거부됩니다.

+

공식 규칙 팩은 용도를 한정한 별도의 서명과 하나의 고정 HTTPS 소스를 사용합니다. 앱은 독립적인 최고 시퀀스 워터마크를 유지하고, 시작할 때 캐시된 정확한 바이트를 다시 검증하며, 롤백과 서로 상충하는 콘텐츠의 제시를 거부합니다. 또한 게시자 서명을 변경된 콘텐츠의 차단 적용을 허용하는 권한으로 취급하지 않습니다. 새 팩이나 변경된 팩에는 정확한 콘텐츠를 명시적으로 검토하고 저장하는 절차가 필요합니다. 오래된 승인 콘텐츠에는 마지막 정상 버전이라는 표시가 붙으며, 신뢰가 취소되었거나 바이트가 손상된 경우에는 안전을 위해 거부됩니다.

취약점 신고하기

GitHub의 비공개 취약점 신고를 이용하시고, GitHub를 쓰고 @@ -223,7 +221,7 @@

취약점 신고하기

diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 2186f98f..7b3605a1 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -62,7 +62,7 @@ Documentation Using Flowlight - Everything from the first launch to tuning the agent rules. Flowlight 0.15.3, macOS 15 or later. + Everything from the first launch to tuning the agent rules. Flowlight 0.15.4, macOS 15 or later. On this page @@ -518,12 +518,19 @@ open build/Build/Products/Release/Flowlight.app advertising delivery and auction hosts; Cross-site tracking covers common analytics, session replay and cross-site measurement hosts; and Privacy and telemetry covers crash reporting, install attribution, and audited Apple advertising and analytics hosts. The packs are visible from the start and all are off by default. - Nothing changes on an update until you choose a pack and enable it. + + Official-only, signed updates. Flowlight checks one fixed HTTPS source for newer publisher-signed catalogs. + Automatic checks are on by default and can be switched off here. A signature authenticates content but does not enable it: + a new pack stays off, and changed destinations remove an enabled pack from enforcement until you review the exact new version + and press Save. If content changes while its editor is open, the stale Save is refused. An accepted catalog may remain available + as visibly stale last-known-good during an outage; rollback, equivocation, revoked trust, or corrupt cached bytes fail closed. Each pack has its own scope. Apply it globally to every app, or only to selected applications. Blocking needs - the Network Extension: the nettop sampler can record traffic, but it cannot sit in a connection's path and refuse it. Essential - system traffic remains allowed — including the services that carry Apple accounts, push notifications, software updates, - certificate checks and iCloud — even when a pack applies globally. + the Network Extension: the nettop sampler can record traffic, but it cannot sit in a connection's path and refuse it. When HTTPS + inspection routes an app through Flowlight's proxy, enabled packs and ordinary destination rules are decided against that original + app before any origin connection opens. This also covers pinned and never-decrypted tunnels by hostname; agent allowlists remain + limited to the direct Network Extension path. Essential system traffic remains allowed — including the services that carry Apple + accounts, push notifications, software updates, certificate checks and iCloud — even when a pack applies globally. Preview before enforcing. Test against recent traffic replays the proposed pack and scope against up to seven days of retained traffic. It reports only decisions that would actually change after ordinary rules, the global pause, @@ -838,9 +845,9 @@ open build/Build/Products/Release/Flowlight.app It shows which extensions are installed, whether macOS actually started Flowlight's filter, and how recently anything was recorded. Paste it into an issue. Does Flowlight send anything about me anywhere? - No account, no telemetry, no analytics, and your traffic database is never uploaded. Three requests do leave the Mac, all listed on the - privacy page: the daily update check and the optional network-owner lookup, both with off switches, and - reverse DNS while Flowlight is on the sampler, which has none — without it the sampler could not name most connections. + No account, no telemetry, no analytics, and your traffic database is never uploaded. Four requests can leave the Mac, all listed on the + privacy page: release and signed rule-pack checks plus the optional network-owner lookup, all with off + switches, and reverse DNS while Flowlight is on the sampler, which has none — without it the sampler could not name most connections. Will it slow my Mac down or drain the battery? With the window closed Flowlight does almost nothing: it keeps the menu bar rates and writes one row a second. The Network Extension costs less than the fallback sampler, which starts a short-lived nettop process every second. @@ -887,7 +894,7 @@ Understand your AI agents. brew install --cask xinbetween/tap/flowlightCopy - v0.15.3macOS 15+UniversalGPL-3.0No telemetry + v0.15.4macOS 15+UniversalGPL-3.0No telemetry connectionslive @@ -1038,7 +1045,10 @@ Optional. What did the agent actually send, and which tool call made it? Decrypt Managed ad and tracker packs Three official packs — Advertising, Cross-site tracking, and Privacy and telemetry — are built in, visible and off by - default. With the Network Extension, apply one to every app or only selected apps; preview it against retained traffic, + default. Flowlight checks one fixed HTTPS feed for signed updates; new or changed content remains out of enforcement until + you review that exact version and press Save. During an outage, verified accepted content is visibly stale last-known-good. + + With the Network Extension, apply one to every app or only selected apps; preview it against retained traffic, see grouped, explainable activity, and add pack-specific exceptions for an app. Ordinary allow rules remain the universal escape hatch, global Pause Blocking applies, and essential system traffic stays allowed. @@ -1256,7 +1266,7 @@ e.g. Wireshark Filtered in the kernelCapture passes only DNS answers and TLS ClientHellos. Everything else is dropped before Flowlight sees it. - Lookups you controlNaming who owns an address sends only public IPs with no hostname to Team Cymru, and turns off in one click. The daily update check only asks GitHub for the latest version, and can be turned off too. + Lookups you controlNaming who owns an address sends only public IPs with no hostname to Team Cymru. Release and signed rule-pack checks send standard HTTPS requests with no traffic or pack settings. Each has an off switch. One agent, every tool call. Traffic from the git, curl and MCP servers Claude Code started counts as Claude Code's. With HTTPS inspection on, the Tool calls tab shows what the model asked for, what came back, and the requests each tool made: @@ -1569,6 +1579,8 @@ Privacy Update downloadgithub.comThe disk image and its checksum fileOnly when you click Download & Open + Official rule-pack updateflowlight.xinbetween.comA standard HTTPS request to the fixed official feed for a publisher-signed catalog. The server sees your IP address, the Flowlight/ user agent, and conditional cache validators; it receives no traffic, pack settings, or device identifierAfter startup when due and then at most daily while healthy. On by default; off switch and manual Check Now in Rules › Packs + Reverse DNSYour configured DNS resolverA lookup of an IP address's registered name, when no hostname was seenAs needed, in the sampler capture mode, which is the default until the Network Extension is running. No off switch: without it the sampler cannot name most connections. The Network Extension names them from the traffic itself and does not do this Network owner lookupTeam Cymru, over DNSPublic IP addresses with no known hostname. Private and reserved addresses never leave your MacOn by default. Off switch in Capture › Identify network owners @@ -1617,8 +1629,31 @@ Releases Downloads, checksums and full notes for each version are on GitHub Releases. + 0.15.4 +October 20, 2026Latest + + Consume verified official rule-pack updates. Flowlight checks its one fixed HTTPS feed with conditional + requests, verifies publisher signatures and compatibility before persistence, and keeps exact last-known-good bytes plus + an independent anti-rollback watermark. + + Keep every policy change consentful. New packs stay off. Changed versions or destinations immediately leave + enforcement while preserving scope, selected apps, exceptions and history; explicit review and Save of the exact new content + restores consent, and a refresh racing an open editor makes its old Save stale. + + Expose trust and failure state without overclaiming. Rules › Packs now shows automatic-refresh controls, + verification status, sequence, source, signer, timestamps, bundled-versus-downloaded provenance, stale last-known-good, and + independent fetch/apply failures. Refresh sends no traffic, pack settings or device identifier. + + Fail closed at startup and on the wire. Capture waits for catalog verification plus saved exceptions; + redirects, MIME, size, timeout, rollback, equivocation, revoked trust, corrupt cache and failed database commits cannot publish + unseen definitions or replace the active snapshot. + + Keep managed packs enforcing during HTTPS inspection. Before the local proxy opens a CONNECT tunnel or + plaintext origin connection, it applies the extension-acknowledged pack snapshot to the original app identity. A refusal is + returned only after its activity record commits; failed persistence allows the connection instead of blocking invisibly. + 0.15.3 -October 19, 2026Latest +October 19, 2026 Publish official rule packs as signed data. Flowlight now defines a purpose-scoped P-256 signature contract for its official managed-pack catalog, with the public verification key included in the @@ -2976,19 +3011,23 @@ Threat model What blocking is, and isn't - A rule that refuses a connection is carried out by the Network Extension, in the path, before the connection is made. A rule - that names a URL is carried out by the inspection proxy instead, which answers the request with a status the app can read. - Those are the only two parts of Flowlight that act rather than report — the nettop sampler can refuse nothing at all, so on a - Mac running the sampler no rule is enforced, and Flowlight says so rather than offering a switch that does nothing. + A rule that refuses a connection is normally carried out by the Network Extension, in the path, before the connection is made. + For traffic routed through HTTPS inspection, the local proxy applies destination rules and enabled managed packs against the + original client before it opens the upstream connection; a rule that names a URL is also carried out there, after decryption, + with a status the app can read. Those are the only two parts of Flowlight that act rather than report — the nettop sampler can + refuse nothing at all, so on a Mac running the sampler no rule is enforced, and Flowlight says so rather than offering a switch + that does nothing. It is still not prevention. An agent that can run a shell can do by hand what a refused tool would have done. A refusal you can undo in one click is a refusal an agent's next attempt may walk through. Local traffic, DNS, Apple's own services and Flowlight's own connections are never refused, because a blocked Mac is a worse outcome than a blocked agent. And enforcement lapses a few seconds after Flowlight stops running: a refusal nobody can see is worse than none. - Inspection and refusal do not combine. Traffic routed through Flowlight's own proxy leaves the Mac as - Flowlight's traffic, and Flowlight never refuses itself — so an app whose requests you are reading is an app whose connections - rules no longer stop. Turning inspection on to see more costs you enforcement, and nothing on screen previously said so. + Inspection preserves hostname refusal, with a boundary. Traffic routed through Flowlight's proxy leaves the + Mac as exempt Flowlight traffic, so the proxy decides ordinary destination rules and enabled managed packs first, using the + original app identity. It can do this even for pinned or never-decrypted tunnels because CONNECT names the host. Agent allowlists + remain a Network Extension decision and do not apply to that proxied upstream leg; traffic with no trustworthy owner or no + hostname fails open rather than being refused invisibly. What Flowlight itself holds @@ -2996,13 +3035,12 @@ Threat model response bodies for three days — the most sensitive thing it ever keeps. Credential headers are redacted by name and by word, which is a heuristic and not a guarantee; a header carrying a secret under a name nothing recognises can be recorded. - The traffic database is never uploaded. Three smaller things do leave the Mac without being asked for, all of them named on - the privacy page: the update check, which asks GitHub for the latest release on launch and - daily; the network-owner lookup, which asks Team Cymru about public addresses that have no hostname; and, while Flowlight is - reading counters instead of running its extension, a reverse-DNS lookup of addresses it saw no name for. The first two have - off switches. The third does not, because on the sampler it is most of how a connection gets a name at all — it goes to the - resolver the Mac already uses, and it stops once the extension takes over and names connections from the traffic itself. - Ask with a hosted model sends far more than any of them, and is the one thing that stays off until you configure it. + The traffic database is never uploaded. Four smaller things do leave the Mac without being asked for, all of them named on + the privacy page: the release update check; the fixed official rule-pack feed, which receives + no traffic or pack settings; the network-owner lookup, which asks Team Cymru about public addresses with no hostname; and, + while Flowlight is reading counters instead of running its extension, reverse DNS through the Mac's configured resolver. + The first three have off switches. Reverse DNS does not, because on the sampler it is most of how a connection gets a name; + it stops once the extension takes over. Ask with a hosted model sends far more than any of them, and stays off until configured. Updates @@ -3010,6 +3048,11 @@ Threat model without one, then verifies that the downloaded app is signed by the same team as the running copy before installing it. An update signed by somebody else is refused even if it is otherwise valid. + Official rule packs use a separate purpose-scoped P-256 signature and one compiled HTTPS source. The app keeps an independent + highest-sequence watermark, reverifies exact cached bytes at launch, rejects rollback and equivocation, and never treats a + publisher signature as permission to enforce changed content. New or changed packs require explicit exact-content review and + Save; stale accepted content is labeled last-known-good, while revoked trust or corrupt bytes fail closed. + Reporting a vulnerability Use GitHub's private vulnerability reporting, or email diff --git a/docs/llms.txt b/docs/llms.txt index 939c0eb5..63570739 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,6 +1,6 @@ # Flowlight -> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.15.3. +> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.15.4. - [Download Flowlight.dmg](https://github.com/xinbetween/flowlight/releases/latest/download/Flowlight.dmg) - [Source code](https://github.com/xinbetween/flowlight) diff --git a/docs/plugins/index.html b/docs/plugins/index.html index 305efd70..25276876 100644 --- a/docs/plugins/index.html +++ b/docs/plugins/index.html @@ -211,7 +211,7 @@

Review checklist

diff --git a/docs/privacy/index.html b/docs/privacy/index.html index 20d72f32..058b3d06 100644 --- a/docs/privacy/index.html +++ b/docs/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -131,6 +131,7 @@

Network requests the app makes

RequestSent toWhat it containsWhen Update checkapi.github.comA standard HTTPS request for the latest release; GitHub sees your IP address and the Flowlight/<version> user agentAfter a clean quit and relaunch, and daily. Off switch in Settings › General Update downloadgithub.comThe disk image and its checksum fileOnly when you click Download & Open + Official rule-pack updateflowlight.xinbetween.comA standard HTTPS request to the fixed official feed for a publisher-signed catalog. The server sees your IP address, the Flowlight/<version> user agent, and conditional cache validators; it receives no traffic, pack settings, or device identifierAfter startup when due and then at most daily while healthy. On by default; off switch and manual Check Now in Rules › Packs Reverse DNSYour configured DNS resolverA lookup of an IP address's registered name, when no hostname was seenAs needed, in the sampler capture mode, which is the default until the Network Extension is running. No off switch: without it the sampler cannot name most connections. The Network Extension names them from the traffic itself and does not do this Network owner lookupTeam Cymru, over DNSPublic IP addresses with no known hostname. Private and reserved addresses never leave your MacOn by default. Off switch in Capture › Identify network owners Ask with a hosted modelThe provider you select: Anthropic, OpenAI, Google Gemini or a compatible endpointYour question and the results returned by the read-only Flowlight queries selected by the model. The traffic database itself is not uploadedOnly when you select a hosted provider and submit a question. The Ask screen shows the transmitted requests @@ -195,7 +196,7 @@

Contact

diff --git a/docs/pt-PT/about/index.html b/docs/pt-PT/about/index.html index 7f106163..f8535b8c 100644 --- a/docs/pt-PT/about/index.html +++ b/docs/pt-PT/about/index.html @@ -152,7 +152,7 @@

Agradecimentos

diff --git a/docs/pt-PT/docs/index.html b/docs/pt-PT/docs/index.html index 522edf1e..930bf7e1 100644 --- a/docs/pt-PT/docs/index.html +++ b/docs/pt-PT/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

Documentação

Usar o Flowlight

-

Tudo, da primeira abertura ao ajuste das regras dos agentes. Flowlight 0.15.3, macOS 15 ou posterior.

+

Tudo, da primeira abertura ao ajuste das regras dos agentes. Flowlight 0.15.4, macOS 15 ou posterior.

diff --git a/docs/pt-PT/index.html b/docs/pt-PT/index.html index bef3f0ea..4713eb8d 100644 --- a/docs/pt-PT/index.html +++ b/docs/pt-PT/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

Veja a atividade de rede das suas apps.

brew install --cask xinbetween/tap/flowlight

-

v0.15.3macOS 15+UniversalGPL-3.0Sem telemetria

+

v0.15.4macOS 15+UniversalGPL-3.0Sem telemetria

@@ -236,6 +236,7 @@

Acompanhe como os agentes de IA usam a rede

software em que não confia — veja o modelo de ameaças.

Cursor ⇥ raw.githubusercontent.com · recusado 4 vezes · dias de semana 18:00–09:00

+
⛔ Por opção

Pacotes geridos de publicidade e rastreadores

Estão integrados três pacotes oficiais, visíveis e desativados por predefinição. O Flowlight consulta um único feed HTTPS fixo para obter atualizações assinadas; os conteúdos novos ou alterados ficam fora da aplicação até rever essa versão exata e premir Guardar. Durante uma indisponibilidade, os conteúdos verificados e aceites podem continuar disponíveis como a última versão válida conhecida, visivelmente assinalada como desatualizada.

Bloqueiam por nome de anfitrião, pelo que não conseguem remover anúncios próprios, espaços vazios no DOM ou no esquema da página, sobreposições do navegador, janelas emergentes criadas por scripts nem janelas nativas das aplicações.

⛔ Recusa

Proteções nas ferramentas de um agente

@@ -435,7 +436,7 @@

Um monitor que não tem de monitorizar

Fica no seu Mac

Um único ficheiro SQLite local. Sem conta, sem sincronização na nuvem, sem telemetria.

Metadados por predefinição

Nomes de anfitrião, portas e contagens de bytes. Desencriptar HTTPS é um modo separado que tem de ativar, e fica tudo no seu Mac.

Filtrado no núcleo

A captura só deixa passar as respostas de DNS e os ClientHello de TLS. Todo o resto é descartado antes de o Flowlight o ver.

-
Consultas sob o seu controlo

Saber quem é o proprietário de um endereço envia à Team Cymru apenas IPs públicos sem nome de anfitrião, e desativa-se num clique. A procura diária de atualizações só pergunta ao GitHub pela versão mais recente e também pode ser desativada.

+
Consultas sob o seu controlo

Saber quem é o proprietário de um endereço envia à Team Cymru apenas IPs públicos sem nome de anfitrião. As verificações de versões e de pacotes de regras assinados enviam pedidos HTTPS normais, sem tráfego nem definições dos pacotes. Cada uma tem o seu próprio interruptor.

@@ -571,7 +572,7 @@

Saiba o que sai do seu Mac.

diff --git a/docs/pt-PT/privacy/index.html b/docs/pt-PT/privacy/index.html index e2cff2c4..452c2b6e 100644 --- a/docs/pt-PT/privacy/index.html +++ b/docs/pt-PT/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -125,6 +125,7 @@

Pedidos de rede que a app faz

PedidoEnviado paraO que contémQuando Procura de atualizaçõesapi.github.comUm pedido HTTPS normal pela versão mais recente; o GitHub vê o seu endereço IP e o agente de utilizador Flowlight/<version>Na abertura e uma vez por dia, a partir da 0.1.2. Interruptor para desativar em Definições › Geral Transferência da atualizaçãogithub.comA imagem de disco e o seu ficheiro de soma de verificaçãoSó quando clica em Transferir e abrir + Atualização oficial do pacote de regrasflowlight.xinbetween.comUm pedido HTTPS padrão ao feed oficial fixo. O servidor vê o seu endereço IP, o agente do utilizador Flowlight/<version> e os validadores condicionais de cache; não recebe tráfego, definições dos pacotes nem qualquer identificador do dispositivoApós o arranque, quando for necessário, e depois, no máximo, uma vez por dia enquanto funcionar normalmente. Ativa por predefinição; opção para desativar e Procurar agora manualmente em Regras › Pacotes DNS inversoO resolvedor de DNS que tem configuradoUma consulta do nome registado de um endereço IP, quando não foi visto nenhum nome de anfitriãoConforme necessário, no modo de captura por amostrador, que é a predefinição até a extensão de rede estar a correr. Sem interruptor para desligar: sem isto, o amostrador não consegue dar nome à maioria das ligações. A extensão de rede dá-lhes nome a partir do próprio tráfego e não faz esta consulta Consulta do proprietário da redeTeam Cymru, por DNSEndereços IP públicos sem nome de anfitrião conhecido. Os endereços privados e reservados nunca saem do seu MacAtivada por predefinição. Interruptor para desativar em Captura › Identificar os proprietários da rede Perguntar com um modelo alojadoO fornecedor que escolher: Anthropic, OpenAI, Google Gemini ou um endpoint compatívelA sua pergunta e os resultados devolvidos pelas consultas de leitura do Flowlight escolhidas pelo modelo. A base de dados de tráfego em si não é enviadaSó quando escolhe um fornecedor alojado e submete uma pergunta. O ecrã Perguntar mostra os pedidos transmitidos @@ -188,7 +189,7 @@

Contacto

diff --git a/docs/pt-PT/threat-model/index.html b/docs/pt-PT/threat-model/index.html index 8f6d8d32..7e4f7030 100644 --- a/docs/pt-PT/threat-model/index.html +++ b/docs/pt-PT/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -154,36 +154,33 @@

O que escapa à inspeção

coisas se aplica, app por app.

O que o bloqueio é, e não é

-

Uma regra que recusa ligações é levada a cabo pela extensão de rede, no caminho, antes de a ligação ser feita. Uma regra que nomeia um URL é - levada a cabo pelo proxy de inspeção, que responde ao pedido com um estado que a app consegue ler. São as duas únicas partes do Flowlight que - agem em vez de relatar — o amostrador nettop não consegue recusar nada, por isso num Mac que capta com o amostrador nenhuma regra é aplicada, e - o Flowlight di-lo em vez de oferecer um interruptor que não faz nada.

+

Uma regra que recusa ligações é normalmente levada a cabo pela extensão de rede, no caminho, antes de a ligação ser feita. Para o tráfego + encaminhado pela inspeção HTTPS, o proxy local aplica as regras de destino e os pacotes geridos ativos ao cliente original antes de abrir a + ligação ao servidor de origem; uma regra que nomeia um URL também é aplicada ali, depois da desencriptação, com um estado que a app consegue ler. + São as duas únicas partes do Flowlight que agem em vez de relatar — o amostrador nettop não consegue recusar nada, por isso num Mac que capta com + o amostrador nenhuma regra é aplicada, e o Flowlight di-lo em vez de oferecer um interruptor que não faz nada.

Continua a não ser impedir. Um agente que consiga executar uma shell pode fazer à mão o que a ferramenta recusada teria feito. Uma recusa que pode desfazer num clique é uma recusa por onde a tentativa seguinte de um agente pode passar. O tráfego local, o DNS, os serviços da Apple e as ligações do próprio Flowlight nunca são recusados, porque um Mac bloqueado é um resultado pior do que um agente bloqueado. E a aplicação efetiva caduca uns segundos depois de o Flowlight deixar de correr: uma recusa que ninguém consegue ver é pior do que nenhuma.

-

A inspeção e a recusa não se combinam. O tráfego encaminhado pelo próprio proxy do Flowlight sai do Mac como tráfego do - Flowlight, e o Flowlight nunca se recusa a si mesmo — por isso uma app cujos pedidos está a ler é uma app cujas ligações as regras já não - travam. Ligar a inspeção para ver mais custa-lhe a aplicação das regras, e até agora nada no ecrã o dizia.

+

A inspeção preserva a recusa por nome de anfitrião, com um limite. O tráfego encaminhado pelo proxy do Flowlight sai do Mac como + tráfego isento do Flowlight, por isso o proxy decide primeiro as regras de destino comuns e os pacotes geridos ativos com base na identidade + original da app. Consegue fazê-lo mesmo em túneis com certificado fixado ou nunca decifrados, porque CONNECT indica o anfitrião. As listas de + permissões dos agentes continuam a ser uma decisão da Extensão de rede e não se aplicam a esse trajeto pelo proxy até ao servidor de origem; o + tráfego sem um proprietário fiável ou sem nome de anfitrião é permitido em vez de ser recusado sem que ninguém o veja.

O que o próprio Flowlight guarda

Metadados de tráfego numa base de dados SQLite local, com uma retenção publicada. Com a inspeção HTTPS ligada, guarda também os corpos dos pedidos e das respostas durante três dias — a coisa mais sensível que chega a manter. Os cabeçalhos de credenciais são ocultados por nome e por palavra, o que é uma heurística e não uma garantia; um cabeçalho que leve um segredo sob um nome que nada reconhece pode ficar registado.

-

A base de dados de tráfego nunca é enviada. Há, ainda assim, três coisas mais pequenas que saem do Mac sem lhe serem pedidas, - todas nomeadas na página de privacidade: a procura de atualizações, que pede ao GitHub a versão mais - recente no arranque e uma vez por dia; a consulta do proprietário da rede, que pergunta ao Team Cymru por endereços públicos sem - nome de anfitrião; e, enquanto o Flowlight lê contadores em vez de correr a sua extensão, uma consulta de DNS inverso dos endereços - para os quais não viu nome. As duas primeiras têm interruptores para desligar. A terceira não tem, porque no amostrador é quase - tudo aquilo que dá um nome a uma ligação — vai para o resolvedor de DNS que o Mac já usa e para quando a extensão assume e passa a - nomear as ligações a partir do próprio tráfego. Perguntar com um modelo alojado envia muito mais do que qualquer uma delas, e é a - única coisa que fica desligada até a configurar.

+

A base de dados de tráfego nunca é enviada. A página de privacidade lista cada pedido externo: verificações de versão e de pacotes de regras assinados, consulta opcional do proprietário da rede e DNS inverso com o amostrador. Os três primeiros têm interruptores; o DNS inverso não, porque sem ele o amostrador não conseguiria nomear a maioria das ligações. Um modelo alojado envia muito mais e fica desligado até o configurar.

Atualizações

As versões são assinadas com um Developer ID e notarizadas. O atualizador exige uma soma de verificação publicada e recusa uma transferência sem ela; depois verifica que a app transferida está assinada pela mesma equipa que a cópia em execução antes de a instalar. Uma atualização assinada por outra pessoa é recusada mesmo que no resto seja válida.

+

Os pacotes de regras oficiais usam uma assinatura distinta e limitada a esta finalidade e uma única fonte HTTPS fixa. A aplicação mantém separadamente um marcador da sequência mais alta, volta a verificar no arranque os bytes exatos guardados em cache, rejeita regressões e publicações contraditórias e nunca trata a assinatura do editor como autorização para aplicar conteúdos alterados. Os pacotes novos ou alterados exigem a revisão explícita do conteúdo exato e Guardar; os conteúdos desatualizados já aceites são assinalados como a última versão válida conhecida, enquanto a revogação da confiança ou bytes corrompidos fazem com que o conteúdo seja recusado por segurança.

Comunicar uma vulnerabilidade

Use a comunicação privada de vulnerabilidades do GitHub, ou envie um email @@ -225,7 +222,7 @@

Comunicar uma vulnerabilidade

diff --git a/docs/releases/index.html b/docs/releases/index.html index 4b61180a..322d3d41 100644 --- a/docs/releases/index.html +++ b/docs/releases/index.html @@ -28,7 +28,7 @@ - + @@ -61,7 +61,28 @@

What's new

-

0.15.3

Latest
+

0.15.4

Latest
+
    +
  • Consume verified official rule-pack updates. Flowlight checks its one fixed HTTPS feed with conditional + requests, verifies publisher signatures and compatibility before persistence, and keeps exact last-known-good bytes plus + an independent anti-rollback watermark.
  • +
  • Keep every policy change consentful. New packs stay off. Changed versions or destinations immediately leave + enforcement while preserving scope, selected apps, exceptions and history; explicit review and Save of the exact new content + restores consent, and a refresh racing an open editor makes its old Save stale.
  • +
  • Expose trust and failure state without overclaiming. Rules › Packs now shows automatic-refresh controls, + verification status, sequence, source, signer, timestamps, bundled-versus-downloaded provenance, stale last-known-good, and + independent fetch/apply failures. Refresh sends no traffic, pack settings or device identifier.
  • +
  • Fail closed at startup and on the wire. Capture waits for catalog verification plus saved exceptions; + redirects, MIME, size, timeout, rollback, equivocation, revoked trust, corrupt cache and failed database commits cannot publish + unseen definitions or replace the active snapshot.
  • +
  • Keep managed packs enforcing during HTTPS inspection. Before the local proxy opens a CONNECT tunnel or + plaintext origin connection, it applies the extension-acknowledged pack snapshot to the original app identity. A refusal is + returned only after its activity record commits; failed persistence allows the connection instead of blocking invisibly.
  • +
+
+ +
+

0.15.3

  • Publish official rule packs as signed data. Flowlight now defines a purpose-scoped P-256 signature contract for its official managed-pack catalog, with the public verification key included in the @@ -1274,7 +1295,7 @@

    What's new

diff --git a/docs/sitemap.xml b/docs/sitemap.xml index 79552091..bfe3b975 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -1,55 +1,55 @@ https://flowlight.xinbetween.com/about/2026-09-25 - https://flowlight.xinbetween.com/docs/2026-10-17 - https://flowlight.xinbetween.com/2026-10-17 + https://flowlight.xinbetween.com/docs/2026-10-22 + https://flowlight.xinbetween.com/2026-10-20 https://flowlight.xinbetween.com/plugins/2026-10-13 - https://flowlight.xinbetween.com/privacy/2026-10-07 - https://flowlight.xinbetween.com/releases/2026-10-19 - https://flowlight.xinbetween.com/threat-model/2026-09-27 + https://flowlight.xinbetween.com/privacy/2026-10-20 + https://flowlight.xinbetween.com/releases/2026-10-22 + https://flowlight.xinbetween.com/threat-model/2026-10-22 https://flowlight.xinbetween.com/de/about/2026-09-26 - https://flowlight.xinbetween.com/de/docs/2026-09-28 - https://flowlight.xinbetween.com/de/2026-10-07 - https://flowlight.xinbetween.com/de/privacy/2026-09-27 - https://flowlight.xinbetween.com/de/threat-model/2026-09-27 + https://flowlight.xinbetween.com/de/docs/2026-10-22 + https://flowlight.xinbetween.com/de/2026-10-20 + https://flowlight.xinbetween.com/de/privacy/2026-10-20 + https://flowlight.xinbetween.com/de/threat-model/2026-10-22 https://flowlight.xinbetween.com/es/about/2026-09-26 - https://flowlight.xinbetween.com/es/docs/2026-09-28 - https://flowlight.xinbetween.com/es/2026-10-07 - https://flowlight.xinbetween.com/es/privacy/2026-09-27 - https://flowlight.xinbetween.com/es/threat-model/2026-09-27 + https://flowlight.xinbetween.com/es/docs/2026-10-22 + https://flowlight.xinbetween.com/es/2026-10-20 + https://flowlight.xinbetween.com/es/privacy/2026-10-20 + https://flowlight.xinbetween.com/es/threat-model/2026-10-22 https://flowlight.xinbetween.com/fr/about/2026-09-26 - https://flowlight.xinbetween.com/fr/docs/2026-09-28 - https://flowlight.xinbetween.com/fr/2026-10-07 - https://flowlight.xinbetween.com/fr/privacy/2026-09-27 - https://flowlight.xinbetween.com/fr/threat-model/2026-09-27 + https://flowlight.xinbetween.com/fr/docs/2026-10-22 + https://flowlight.xinbetween.com/fr/2026-10-20 + https://flowlight.xinbetween.com/fr/privacy/2026-10-20 + https://flowlight.xinbetween.com/fr/threat-model/2026-10-22 https://flowlight.xinbetween.com/it/about/2026-09-26 - https://flowlight.xinbetween.com/it/docs/2026-09-28 - https://flowlight.xinbetween.com/it/2026-10-07 - https://flowlight.xinbetween.com/it/privacy/2026-09-27 - https://flowlight.xinbetween.com/it/threat-model/2026-09-27 + https://flowlight.xinbetween.com/it/docs/2026-10-22 + https://flowlight.xinbetween.com/it/2026-10-20 + https://flowlight.xinbetween.com/it/privacy/2026-10-20 + https://flowlight.xinbetween.com/it/threat-model/2026-10-22 https://flowlight.xinbetween.com/pt-PT/about/2026-09-26 - https://flowlight.xinbetween.com/pt-PT/docs/2026-09-28 - https://flowlight.xinbetween.com/pt-PT/2026-10-07 - https://flowlight.xinbetween.com/pt-PT/privacy/2026-09-27 - https://flowlight.xinbetween.com/pt-PT/threat-model/2026-09-27 + https://flowlight.xinbetween.com/pt-PT/docs/2026-10-22 + https://flowlight.xinbetween.com/pt-PT/2026-10-20 + https://flowlight.xinbetween.com/pt-PT/privacy/2026-10-20 + https://flowlight.xinbetween.com/pt-PT/threat-model/2026-10-22 https://flowlight.xinbetween.com/ja/about/2026-09-26 - https://flowlight.xinbetween.com/ja/docs/2026-09-28 - https://flowlight.xinbetween.com/ja/2026-10-07 - https://flowlight.xinbetween.com/ja/privacy/2026-09-27 - https://flowlight.xinbetween.com/ja/threat-model/2026-09-27 + https://flowlight.xinbetween.com/ja/docs/2026-10-22 + https://flowlight.xinbetween.com/ja/2026-10-20 + https://flowlight.xinbetween.com/ja/privacy/2026-10-20 + https://flowlight.xinbetween.com/ja/threat-model/2026-10-22 https://flowlight.xinbetween.com/zh-Hans/about/2026-09-26 - https://flowlight.xinbetween.com/zh-Hans/docs/2026-09-28 - https://flowlight.xinbetween.com/zh-Hans/2026-10-07 - https://flowlight.xinbetween.com/zh-Hans/privacy/2026-09-27 - https://flowlight.xinbetween.com/zh-Hans/threat-model/2026-09-27 + https://flowlight.xinbetween.com/zh-Hans/docs/2026-10-22 + https://flowlight.xinbetween.com/zh-Hans/2026-10-20 + https://flowlight.xinbetween.com/zh-Hans/privacy/2026-10-20 + https://flowlight.xinbetween.com/zh-Hans/threat-model/2026-10-22 https://flowlight.xinbetween.com/zh-Hant/about/2026-09-26 - https://flowlight.xinbetween.com/zh-Hant/docs/2026-09-28 - https://flowlight.xinbetween.com/zh-Hant/2026-10-07 - https://flowlight.xinbetween.com/zh-Hant/privacy/2026-09-27 - https://flowlight.xinbetween.com/zh-Hant/threat-model/2026-09-27 + https://flowlight.xinbetween.com/zh-Hant/docs/2026-10-22 + https://flowlight.xinbetween.com/zh-Hant/2026-10-20 + https://flowlight.xinbetween.com/zh-Hant/privacy/2026-10-20 + https://flowlight.xinbetween.com/zh-Hant/threat-model/2026-10-22 https://flowlight.xinbetween.com/ko/about/2026-09-26 - https://flowlight.xinbetween.com/ko/docs/2026-09-28 - https://flowlight.xinbetween.com/ko/2026-10-07 - https://flowlight.xinbetween.com/ko/privacy/2026-09-27 - https://flowlight.xinbetween.com/ko/threat-model/2026-09-27 + https://flowlight.xinbetween.com/ko/docs/2026-10-22 + https://flowlight.xinbetween.com/ko/2026-10-20 + https://flowlight.xinbetween.com/ko/privacy/2026-10-20 + https://flowlight.xinbetween.com/ko/threat-model/2026-10-22 diff --git a/docs/threat-model/index.html b/docs/threat-model/index.html index 71fb23cc..6bdcaa18 100644 --- a/docs/threat-model/index.html +++ b/docs/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -154,34 +154,41 @@

What inspection misses

per app.

What blocking is, and isn't

-

A rule that refuses a connection is carried out by the Network Extension, in the path, before the connection is made. A rule - that names a URL is carried out by the inspection proxy instead, which answers the request with a status the app can read. - Those are the only two parts of Flowlight that act rather than report — the nettop sampler can refuse nothing at all, so on a - Mac running the sampler no rule is enforced, and Flowlight says so rather than offering a switch that does nothing.

+

A rule that refuses a connection is normally carried out by the Network Extension, in the path, before the connection is made. + For traffic routed through HTTPS inspection, the local proxy applies destination rules and enabled managed packs against the + original client before it opens the upstream connection; a rule that names a URL is also carried out there, after decryption, + with a status the app can read. Those are the only two parts of Flowlight that act rather than report — the nettop sampler can + refuse nothing at all, so on a Mac running the sampler no rule is enforced, and Flowlight says so rather than offering a switch + that does nothing.

It is still not prevention. An agent that can run a shell can do by hand what a refused tool would have done. A refusal you can undo in one click is a refusal an agent's next attempt may walk through. Local traffic, DNS, Apple's own services and Flowlight's own connections are never refused, because a blocked Mac is a worse outcome than a blocked agent. And enforcement lapses a few seconds after Flowlight stops running: a refusal nobody can see is worse than none.

-

Inspection and refusal do not combine. Traffic routed through Flowlight's own proxy leaves the Mac as - Flowlight's traffic, and Flowlight never refuses itself — so an app whose requests you are reading is an app whose connections - rules no longer stop. Turning inspection on to see more costs you enforcement, and nothing on screen previously said so.

+

Inspection preserves hostname refusal, with a boundary. Traffic routed through Flowlight's proxy leaves the + Mac as exempt Flowlight traffic, so the proxy decides ordinary destination rules and enabled managed packs first, using the + original app identity. It can do this even for pinned or never-decrypted tunnels because CONNECT names the host. Agent allowlists + remain a Network Extension decision and do not apply to that proxied upstream leg; traffic with no trustworthy owner or no + hostname fails open rather than being refused invisibly.

What Flowlight itself holds

Traffic metadata in a local SQLite database, with published retention. With HTTPS inspection on it also holds request and response bodies for three days — the most sensitive thing it ever keeps. Credential headers are redacted by name and by word, which is a heuristic and not a guarantee; a header carrying a secret under a name nothing recognises can be recorded.

-

The traffic database is never uploaded. Three smaller things do leave the Mac without being asked for, all of them named on - the privacy page: the update check, which asks GitHub for the latest release on launch and - daily; the network-owner lookup, which asks Team Cymru about public addresses that have no hostname; and, while Flowlight is - reading counters instead of running its extension, a reverse-DNS lookup of addresses it saw no name for. The first two have - off switches. The third does not, because on the sampler it is most of how a connection gets a name at all — it goes to the - resolver the Mac already uses, and it stops once the extension takes over and names connections from the traffic itself. - Ask with a hosted model sends far more than any of them, and is the one thing that stays off until you configure it.

+

The traffic database is never uploaded. Four smaller things do leave the Mac without being asked for, all of them named on + the privacy page: the release update check; the fixed official rule-pack feed, which receives + no traffic or pack settings; the network-owner lookup, which asks Team Cymru about public addresses with no hostname; and, + while Flowlight is reading counters instead of running its extension, reverse DNS through the Mac's configured resolver. + The first three have off switches. Reverse DNS does not, because on the sampler it is most of how a connection gets a name; + it stops once the extension takes over. Ask with a hosted model sends far more than any of them, and stays off until configured.

Updates

Releases are signed with a Developer ID and notarized. The updater requires a published checksum and refuses a download without one, then verifies that the downloaded app is signed by the same team as the running copy before installing it. An update signed by somebody else is refused even if it is otherwise valid.

+

Official rule packs use a separate purpose-scoped P-256 signature and one compiled HTTPS source. The app keeps an independent + highest-sequence watermark, reverifies exact cached bytes at launch, rejects rollback and equivocation, and never treats a + publisher signature as permission to enforce changed content. New or changed packs require explicit exact-content review and + Save; stale accepted content is labeled last-known-good, while revoked trust or corrupt bytes fail closed.

Reporting a vulnerability

Use GitHub's private vulnerability reporting, or email @@ -224,7 +231,7 @@

Reporting a vulnerability

diff --git a/docs/zh-Hans/about/index.html b/docs/zh-Hans/about/index.html index 7c9f6bad..bb48a579 100644 --- a/docs/zh-Hans/about/index.html +++ b/docs/zh-Hans/about/index.html @@ -152,7 +152,7 @@

致谢

diff --git a/docs/zh-Hans/docs/index.html b/docs/zh-Hans/docs/index.html index bb84840a..3c746bd4 100644 --- a/docs/zh-Hans/docs/index.html +++ b/docs/zh-Hans/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

文档

使用 Flowlight

-

从第一次启动到调整代理规则,需要的都在这里。Flowlight 0.15.3,macOS 15 或更高版本。

+

从第一次启动到调整代理规则,需要的都在这里。Flowlight 0.15.4,macOS 15 或更高版本。

diff --git a/docs/zh-Hans/index.html b/docs/zh-Hans/index.html index 8ee8d50b..9e371907 100644 --- a/docs/zh-Hans/index.html +++ b/docs/zh-Hans/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

看清应用的网络活动。

brew install --cask xinbetween/tap/flowlight

-

v0.15.3macOS 15+通用架构GPL-3.0无遥测

+

v0.15.4macOS 15+通用架构GPL-3.0无遥测

@@ -235,6 +235,7 @@

看住 AI 代理如何使用网络

它是对你本来就信任的软件的管控,不是一道拦住你不信任的软件的屏障——见威胁模型。

Cursor ⇥ raw.githubusercontent.com · 已拒绝 4 次 · 工作日 18:00–09:00

+
⛔ 需自行开启

托管广告和跟踪器包

三个官方包均已内置,从一开始就可见,但默认全部关闭。Flowlight 会从一个固定的 HTTPS 源检查签名更新;新增或变更的内容在你审查确切版本并按下保存之前,不会进入强制执行。服务中断期间,经过验证并已接受的内容可能会作为最后已知正常版本保留,并明确标示为已过期。

这些包按主机名阻止,因此无法移除第一方广告、DOM 或布局空白、浏览器叠加层、脚本生成的弹窗或原生应用窗口。

⛔ 拒绝

给代理的工具加护栏

@@ -432,7 +433,7 @@

一个不用你盯着的监控工具

只留在你的 Mac 上

一个本地 SQLite 文件。没有账号,不做云同步,不收遥测。

默认只看元数据

主机名、端口和字节数。解密 HTTPS 是另一个要你自己打开的模式,而且同样只在你的 Mac 上。

在内核里就筛掉

捕获只放行 DNS 应答和 TLS ClientHello。其余一切在 Flowlight 看到之前就被丢掉了。

-
查询由你掌控

要查出某个地址归谁所有,只会把没有主机名的公网 IP 发给 Team Cymru,一键即可关闭。每日更新检查只向 GitHub 询问最新版本,同样可以关掉。

+
查询由你掌控

要查出某个地址归谁所有,只会把没有主机名的公网 IP 发给 Team Cymru。版本和签名规则包检查会发送不含流量或规则包设置的标准 HTTPS 请求。两项均有各自的关闭开关。

@@ -568,7 +569,7 @@

知道什么离开了你的 Mac。

diff --git a/docs/zh-Hans/privacy/index.html b/docs/zh-Hans/privacy/index.html index 87172653..0c5ebe6f 100644 --- a/docs/zh-Hans/privacy/index.html +++ b/docs/zh-Hans/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -124,6 +124,7 @@

应用发出的网络请求

请求发往包含什么什么时候 更新检查api.github.com一个查询最新版本的标准 HTTPS 请求;GitHub 会看到你的 IP 地址和 Flowlight/<version> 这个 User-Agent 标识启动时以及每天一次,自 0.1.2 起。关闭开关在设置 › 通用 更新下载github.com磁盘映像及其校验和文件只在你点击“下载并打开”时 + 官方规则包更新flowlight.xinbetween.com向固定官方源发送的标准 HTTPS 请求。服务器会看到你的 IP 地址、Flowlight/<version>用户代理和缓存条件验证器;不会收到任何流量、包设置或设备标识符启动后在检查到期时执行,此后在运行正常的情况下最多每天执行一次。默认开启;可在规则 › 包中关闭或手动选择立即检查 反向 DNS你配置的 DNS 解析器在没有看到主机名时,查询某个 IP 地址登记的名称按需进行,在采样器捕获模式下,也就是网络扩展开始运行之前的默认模式。没有关闭开关:没有它,采样器就没法给大多数连接命名。网络扩展从流量本身给它们命名,因此不做这件事 网络所有者查询Team Cymru,通过 DNS没有已知主机名的公网 IP 地址。私有地址和保留地址绝不离开你的 Mac默认开启。关闭开关在捕获 › 识别网络所有者 用托管模型提问你选择的提供方:Anthropic、OpenAI、Google Gemini 或兼容的端点你的问题,以及模型选中的那些只读 Flowlight 查询所返回的结果。流量数据库本身不会被上传只在你选择了托管提供方并提交问题时。提问界面会显示发送出去的请求 @@ -186,7 +187,7 @@

联系方式

diff --git a/docs/zh-Hans/threat-model/index.html b/docs/zh-Hans/threat-model/index.html index e2798f2f..d6966e62 100644 --- a/docs/zh-Hans/threat-model/index.html +++ b/docs/zh-Hans/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -147,31 +147,29 @@

检查会漏掉什么

所以,检查界面是空的,意思是“没有东西被转发到这里”,而不是“什么都没发生”。到底是哪一种,覆盖范围会按应用逐个说明。

阻止是什么,不是什么

-

一条拒绝连接的规则由网络扩展执行,就在连接的路径上,在连接建立之前。一条指名 URL 的规则则由检查用的代理服务器执行, - 它用应用能读到的状态来回答那个请求。这两处是 Flowlight 里唯一动手而不只是报告的部分——nettop 采样器什么都拒绝不了, - 所以在用采样器的 Mac 上,没有任何规则会被强制执行,而 Flowlight 会把这一点说出来,而不是摆一个什么都不做的开关。

+

一条拒绝连接的规则通常由网络扩展执行,就在连接的路径上,在连接建立之前。对于经由 HTTPS 检查转发的流量,本地代理会在 + 打开源站连接之前,针对原客户端应用目标规则和已启用的托管包;一条指名 URL 的规则也会在解密后由它执行,并用应用能读到的 + 状态作出响应。这两处是 Flowlight 里唯一动手而不只是报告的部分——nettop 采样器什么都拒绝不了,所以在用采样器的 Mac 上, + 没有任何规则会被强制执行,而 Flowlight 会把这一点说出来,而不是摆一个什么都不做的开关。

它仍然不是防护。一个能运行 shell 的代理,可以手动做到那个被拒绝的工具本来要做的事。一次你能一键撤销的拒绝, 也是代理下一次尝试就可能走过去的拒绝。本地流量、DNS、Apple 自家的服务以及 Flowlight 自己的连接永远不会被拒绝, 因为一台被封住的 Mac 比一个被封住的代理更糟。而且 Flowlight 停止运行几秒之后,强制执行就会失效: 没人能看见的拒绝比没有拒绝更糟。

-

检查与拒绝无法并存。经由 Flowlight 自己的代理服务器转发的流量,是以 Flowlight 的流量的身份离开这台 Mac 的, - 而 Flowlight 从不拒绝自己——所以一个你正在读它请求的应用,也就是一个规则不再拦得住它连接的应用。为了看得更多而打开检查, - 代价就是强制执行,而画面上此前并没有说这一点。

+

检查仍能按主机名拒绝连接,但有一条边界。经由 Flowlight 代理转发的流量,会作为豁免的 Flowlight 流量离开 + Mac,因此代理会先使用原应用的身份,对普通目标规则和已启用的托管包作出判定。即使隧道固定了证书或始终不解密,也能这样判定, + 因为 CONNECT 会指明主机。代理允许列表仍由网络扩展判定,不适用于从代理到源站的这一段路径;没有可信所有者或主机名的流量会 + 被允许,而不会在无人察觉的情况下遭到拒绝。

Flowlight 自己保存了什么

流量元数据,存在本地的 SQLite 数据库里,保留期限是公布的。打开 HTTPS 检查后,它还会保存请求和响应的正文三天—— 这是它所保存的东西里最敏感的。凭据标头会按名称、也按其中的词来遮蔽,那是一种启发式判断,不是保证; 一个把密钥放在没人认得的名字底下的标头,是可能被记录下来的。

-

流量数据库从不上传。不过确实有三样比较小的东西,会在你没有要求的情况下离开这台 Mac,它们都在隐私页面上 - 逐项列出:更新检查,它在启动时和每天一次向 GitHub 询问最新的发布;网络所有者查询,它就没有主机名的公网地址向 Team Cymru - 询问;以及,当 Flowlight 在读计数器而不是在跑它的扩展时,对那些它没看到名字的地址做的一次反向 DNS 查询。前两样都有关闭 - 开关。第三样没有,因为在采样器上,一条连接能有名字,大半就靠它——这次查询会走这台 Mac 本来就在用的解析器,而一旦扩展 - 接手、从流量本身给连接命名,它就停了。用托管模型提问,送出去的东西比它们任何一个都多得多,而它也是唯一在你配置之前 - 一直关着的。

+

流量数据库从不上传。隐私页面列出了每项外部请求:版本和签名规则包检查、可选的网络所有者查询,以及采样器的反向 DNS。前三项都有关闭开关;反向 DNS 没有,因为没有它,采样器无法为大多数连接命名。托管模型会发送更多内容,并且在配置前保持关闭。

更新

版本以 Developer ID 签名并经过公证。更新程序要求有公布的校验和,没有就拒绝下载;随后会在安装之前核对下载到的应用 是否由与运行中副本相同的团队签名。由别人签名的更新会被拒绝,即使它在其他方面都有效。

+

官方规则包使用一套用途受限的独立签名和一个固定的 HTTPS 源。应用会独立保存最高序列号水位,在启动时重新验证缓存的确切字节,拒绝回滚和相互矛盾的发布,并且绝不会把发布者签名当作强制执行变更内容的许可。新增或变更的包必须经过对确切内容的明确审查并保存;已接受但过期的内容会标示为最后已知正常版本;如果信任已撤销或字节损坏,则一律按拒绝处理。

报告漏洞

请使用 GitHub 的私密漏洞报告,或者如果你不愿意用 @@ -212,7 +210,7 @@

报告漏洞

diff --git a/docs/zh-Hant/about/index.html b/docs/zh-Hant/about/index.html index 361918c0..34b3288b 100644 --- a/docs/zh-Hant/about/index.html +++ b/docs/zh-Hant/about/index.html @@ -152,7 +152,7 @@

致謝

diff --git a/docs/zh-Hant/docs/index.html b/docs/zh-Hant/docs/index.html index 2695265d..cbb844b9 100644 --- a/docs/zh-Hant/docs/index.html +++ b/docs/zh-Hant/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

說明文件

使用 Flowlight

-

從第一次啟動到調整代理規則,全都在這裡。Flowlight 0.15.3,macOS 15 或以上版本。

+

從第一次啟動到調整代理規則,全都在這裡。Flowlight 0.15.4,macOS 15 或以上版本。

diff --git a/docs/zh-Hant/index.html b/docs/zh-Hant/index.html index ef043f92..19e48c3f 100644 --- a/docs/zh-Hant/index.html +++ b/docs/zh-Hant/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

看見每個 App 的網路活動。
brew install --cask xinbetween/tap/flowlight

-

v0.15.3macOS 15+通用架構GPL-3.0無遙測

+

v0.15.4macOS 15+通用架構GPL-3.0無遙測

@@ -235,6 +235,7 @@

看住 AI 代理怎麼用網路

它是對你本來就信任的軟體的管控,不是一道擋住你不信任的軟體的屏障 — 見威脅模型。

Cursor ⇥ raw.githubusercontent.com · 已拒絕 4 次 · 平日 18:00–09:00

+
⛔ 需自行開啟

受管理的廣告與追蹤器套件

三個官方套件均已內建,從一開始便可見,但預設全部關閉。Flowlight 會從一個固定的 HTTPS 來源檢查已簽署的更新;新增或變更的內容在你檢閱確切版本並按下儲存之前,不會進入強制執行。服務中斷期間,經過驗證且已接受的內容可能會以已知正常的最後版本保留,並明確標示為已過時。

這些套件依主機名稱封鎖,因此無法移除第一方廣告、DOM 或版面配置空白、瀏覽器覆疊層、由指令碼產生的彈出式視窗或原生 App 視窗。

⛔ 拒絕

為代理的工具設下防護機制

@@ -433,7 +434,7 @@

一個不用你盯著的監控工具

只留在你的 Mac 上

一個本機 SQLite 檔案。不用帳號、沒有雲端同步、沒有遙測。

預設只看後設資料

主機名稱、連接埠和位元組計數。解密 HTTPS 是另一個必須由你開啟的模式,而且一切都留在你的 Mac 上。

在核心層就先過濾

擷取只放行 DNS 回應和 TLS ClientHello。其他所有內容在 Flowlight 看到之前就被丟掉了。

-
查詢由你作主

要辨識某個位址屬於誰,只會把沒有主機名稱的公開 IP 傳給 Team Cymru,而且一鍵就能關閉。每天的更新檢查只是向 GitHub 查詢最新版本,同樣也可以關掉。

+
查詢由你作主

要辨識某個位址屬於誰,只會把沒有主機名稱的公開 IP 傳給 Team Cymru。版本與已簽署規則套件檢查會傳送不含流量或套件設定的標準 HTTPS 請求。兩者都有各自的關閉開關。

@@ -569,7 +570,7 @@

知道有什麼離開了你的 Mac。

diff --git a/docs/zh-Hant/privacy/index.html b/docs/zh-Hant/privacy/index.html index 60a7266c..a1d79b32 100644 --- a/docs/zh-Hant/privacy/index.html +++ b/docs/zh-Hant/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -124,6 +124,7 @@

這個 App 發出的網路請求

請求送到哪裡內容是什麼什麼時候 更新檢查api.github.com一個查詢最新版本的標準 HTTPS 請求;GitHub 會看到你的 IP 位址和 Flowlight/<version> 這個 user agent啟動時以及每天一次,自 0.1.2 起。關閉開關在「設定」› 「一般」 更新下載github.com磁碟映像檔及其總和檢查碼檔案只有在你點「下載並開啟」時 + 官方規則套件更新flowlight.xinbetween.com向固定官方來源傳送的標準 HTTPS 要求。伺服器會看到你的 IP 位址、Flowlight/<version>使用者代理程式和條件式快取驗證器;不會收到任何流量、套件設定或裝置識別碼啟動後在檢查到期時執行,此後在運作正常的情況下每天最多執行一次。預設開啟;可在規則 › 套件中關閉或手動選擇立即檢查 反向 DNS你設定的 DNS 解析器在沒有看到主機名稱時,查詢某個 IP 位址登記的名稱需要時進行,在取樣器擷取模式下,也就是網路延伸功能開始運作之前的預設模式。沒有關閉開關:少了它,取樣器就沒辦法為大多數連線命名。網路延伸功能會從流量本身為它們命名,因此不做這件事 網路擁有者查詢Team Cymru,透過 DNS沒有已知主機名稱的公開 IP 位址。私有和保留位址絕不會離開你的 Mac預設開啟。關閉開關在「擷取」› 「辨識網路擁有者」 用託管模型提問你選擇的供應商:Anthropic、OpenAI、Google Gemini 或相容的端點你的問題,以及模型所選用的 Flowlight 唯讀查詢回傳的結果。流量資料庫本身不會被上傳只有在你選擇託管供應商並送出問題時。「提問」畫面會顯示送出的請求 @@ -186,7 +187,7 @@

聯絡

diff --git a/docs/zh-Hant/threat-model/index.html b/docs/zh-Hant/threat-model/index.html index 74561756..75f56686 100644 --- a/docs/zh-Hant/threat-model/index.html +++ b/docs/zh-Hant/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -148,31 +148,29 @@

檢查會漏掉什麼

逐個說明。

封鎖是什麼,不是什麼

-

一條拒絕連線的規則是由網路延伸功能執行的,就在連線的路徑上,在連線建立之前。一條指名 URL 的規則則由檢查用的代理伺服器 - 執行,它用 App 讀得到的狀態來回答那個請求。這兩處是 Flowlight 裡唯一動手、而不只是回報的部分 — nettop 取樣器什麼都拒絕不了, - 所以在用取樣器的 Mac 上,沒有任何規則會被強制執行,而 Flowlight 會把這一點說出來,而不是擺一個什麼都不做的開關。

+

一條拒絕連線的規則通常由網路延伸功能執行,就在連線的路徑上,在連線建立之前。對於轉送經過 HTTPS 檢查的流量,本機代理伺服器 + 會在開啟來源伺服器連線之前,針對原始用戶端套用目的地規則和已啟用的受管理套件;一條指名 URL 的規則也會在解密後由它執行, + 並用 App 讀得到的狀態作出回應。這兩處是 Flowlight 裡唯一動手、而不只是回報的部分 — nettop 取樣器什麼都拒絕不了,所以在用 + 取樣器的 Mac 上,沒有任何規則會被強制執行,而 Flowlight 會把這一點說出來,而不是擺一個什麼都不做的開關。

但它依然不是防護。一個還能執行 shell 的代理,可以自己動手做那個被拒絕的工具原本要做的事。一次你能一鍵解除的拒絕, 也是代理下一次嘗試就可能走過去的拒絕。本機流量、DNS、Apple 自己的服務,以及 Flowlight 自己的連線永遠不會被拒絕, 因為一台被封死的 Mac 比一個被封住的代理更糟。而且 Flowlight 停止執行後幾秒,強制執行就會失效: 沒人看得到的拒絕比不拒絕更糟。

-

檢查與拒絕無法並存。經由 Flowlight 自己的代理伺服器轉送的流量,是以 Flowlight 的流量的身分離開這台 Mac 的, - 而 Flowlight 從不拒絕自己 — 所以一個你正在讀它請求的 App,也就是一個規則再也攔不住它連線的 App。為了看得更多而開啟檢查, - 代價就是強制執行,而畫面上先前並沒有說這件事。

+

檢查仍能按主機名稱拒絕連線,但有一條界線。轉送經過 Flowlight 代理伺服器的流量,會以豁免的 Flowlight + 流量身分離開 Mac,因此代理伺服器會先使用原 App 的身分,對一般目的地規則和已啟用的受管理套件作出判定。即使通道使用憑證綁定 + 或始終不解密,也能這樣判定,因為 CONNECT 會指明主機。代理的允許清單仍由網路延伸功能判定,不適用於從代理伺服器到來源伺服器 + 的這一段路徑;沒有可信擁有者或主機名稱的流量會被允許,而不會在無人察覺的情況下遭到拒絕。

Flowlight 自己留著什麼

流量的後設資料,放在本機的 SQLite 資料庫裡,保留期限有公布。開啟 HTTPS 檢查後,它還會把請求與回應的內文留 3 天 — 那是它留下來的東西裡最敏感的。憑證類標頭會依名稱、也依其中的字詞遮蔽,那是一種推測規則,不是保證; 一個把祕密放在沒人認得的名稱底下的標頭,是可能被記錄下來的。

-

流量資料庫從不上傳。不過確實有三樣比較小的東西,會在你沒有要求的情況下離開這台 Mac,它們都在隱私頁面上 - 逐項列出:更新檢查,它在啟動時和每天一次向 GitHub 詢問最新的發布;網路擁有者查詢,它就沒有主機名稱的公開位址向 Team Cymru - 詢問;以及,當 Flowlight 在讀計數器而不是執行它的延伸功能時,對那些它沒看到名稱的位址所做的一次反向 DNS 查詢。前兩樣都有 - 關閉開關。第三樣沒有,因為在取樣器上,一條連線能有名稱,大半就靠它 — 這個查詢會走這台 Mac 本來就在用的解析器,而一旦 - 延伸功能接手、從流量本身為連線命名,它就停了。用託管模型「提問」送出去的東西,比它們任何一個都多得多,而它也是唯一在你 - 設定之前一直關著的。

+

流量資料庫從不上傳。隱私頁面列出每項外部要求:版本與簽署規則套件檢查、選用的網路擁有者查詢,以及取樣器的反向 DNS。前三項都有關閉開關;反向 DNS 沒有,因為少了它,取樣器無法為大多數連線命名。託管模型會傳送更多內容,並在設定前保持關閉。

更新

版本以 Developer ID 簽章並經過公證。更新程式要求有公布的總和檢查碼,沒有就拒絕下載;接著會在安裝之前確認下載到的 App 是否由與執行中副本相同的團隊簽章。由別人簽章的更新會被拒絕,即使它在其他方面都有效。

+

官方規則套件使用一套用途受限的獨立簽章和一個固定的 HTTPS 來源。App 會獨立保存最高序號水位,在啟動時重新驗證快取的確切位元組,拒絕回復舊版和相互矛盾的發佈,並且絕不會把發佈者簽章視為強制執行變更內容的許可。新增或變更的套件必須經過對確切內容的明確檢閱並儲存;已接受但過時的內容會標示為已知正常的最後版本;如果信任已撤銷或位元組損壞,則一律以拒絕處理。

回報漏洞

請使用 GitHub 的私密漏洞回報,或者如果你不想用 @@ -213,7 +211,7 @@

回報漏洞

diff --git a/project.yml b/project.yml index dc8e200d..f5e5135f 100644 --- a/project.yml +++ b/project.yml @@ -11,8 +11,8 @@ settings: DEVELOPMENT_TEAM: "" CODE_SIGN_STYLE: Automatic ENABLE_HARDENED_RUNTIME: YES - MARKETING_VERSION: "0.15.3" - CURRENT_PROJECT_VERSION: "39" + MARKETING_VERSION: "0.15.4" + CURRENT_PROJECT_VERSION: "41" targets: Flowlight: type: application diff --git a/scripts/build-pkg.sh b/scripts/build-pkg.sh index 1174b61f..47a10398 100755 --- a/scripts/build-pkg.sh +++ b/scripts/build-pkg.sh @@ -23,14 +23,49 @@ cp -R "$APP" "$WORK/root/" pkgbuild --analyze --root "$WORK/root" "$WORK/components.plist" >/dev/null plutil -replace 0.BundleIsRelocatable -bool NO "$WORK/components.plist" +# pkgbuild serializes every extended attribute as a ._ AppleDouble payload +# record. Newer macOS releases pin com.apple.provenance on signed files, so the +# attribute survives COPYFILE_DISABLE and even xattr -d. Let pkgbuild create the +# component metadata, then replace only its payload archive and BOM. cpio keeps +# the signed bundle bytes and POSIX modes while COPYFILE_DISABLE omits xattrs. +# Match pkgbuild's recommended system-domain ownership in both archives: an +# installer must not leave /Applications/Flowlight.app owned by the build user. pkgbuild --root "$WORK/root" --component-plist "$WORK/components.plist" --install-location /Applications \ - --identifier com.flowlight.app.pkg --version "$VERSION" "$WORK/Flowlight-component.pkg" + --identifier com.flowlight.app.pkg --version "$VERSION" "$WORK/Flowlight-component-with-metadata.pkg" +pkgutil --expand "$WORK/Flowlight-component-with-metadata.pkg" "$WORK/component-expanded" +WORK_ABS="$PWD/$WORK" +( + cd "$WORK/root" + find . -print | cpio -o -R root:wheel --format odc 2> "$WORK_ABS/cpio.log" \ + | gzip -9 > "$WORK_ABS/component-expanded/Payload" +) +# pkgbuild's BOM already has the right root:wheel ownership and checksums. Drop +# only the AppleDouble basenames it synthesized from pinned extended attributes. +lsbom "$WORK/component-expanded/Bom" \ + | perl -F'\t' -lane '$name = (split m{/}, $F[0])[-1]; print unless $name =~ /^\._/' \ + > "$WORK/bom-files.txt" +mkbom -i "$WORK/bom-files.txt" "$WORK/clean.bom" +mv "$WORK/clean.bom" "$WORK/component-expanded/Bom" +PAYLOAD_COUNT=$(lsbom -s "$WORK/component-expanded/Bom" | wc -l | tr -d ' ') +PAYLOAD_COUNT="$PAYLOAD_COUNT" perl -0pi -e \ + 's{( "$PAYLOAD_FILES" +if grep -Eq '/\._|/\.__|^\./\._' "$PAYLOAD_FILES"; then + echo "Package contains AppleDouble metadata files." >&2 + exit 1 +fi +[[ $(wc -l < "$PAYLOAD_FILES" | tr -d ' ') == "$PAYLOAD_COUNT" ]] + # A signed installer still needs notarizing, or macOS refuses to open it. if [[ ${#SIGN[@]} -gt 0 ]]; then scripts/notarize.sh "build/Flowlight-$VERSION.pkg"; fi echo "Built build/Flowlight-$VERSION.pkg" diff --git a/scripts/check_site.py b/scripts/check_site.py index a3a7a398..4628f770 100644 --- a/scripts/check_site.py +++ b/scripts/check_site.py @@ -22,6 +22,7 @@ OFFICIAL_PACK_OUTPUT = OUT / "packs/v1/official.json" OFFICIAL_PACK_PUBLIC_KEY = ROOT / "Flowlight/Resources/RulePacks/official-rule-packs-v1-public.pem" OFFICIAL_PACK_KEY_ID = "flowlight-rule-packs-2026-01" +OFFICIAL_PACK_URL = "https://flowlight.xinbetween.com/packs/v1/official.json" PROJECT = (ROOT / "project.yml").read_text() APP_VERSION = re.search(r'MARKETING_VERSION:\s*"([^"]+)"', PROJECT).group(1) @@ -90,6 +91,40 @@ def problems(): detail = (verified.stderr or verified.stdout).strip().replace("\n", " ") found.append(f"site/packs/v1/official.json: signature verification failed: {detail}") + # The app now consumes this endpoint. Keep the fixed source, privacy disclosure, documentation and release notes + # tied together so a future refactor cannot silently create an undocumented outbound request or arbitrary source. + verifier_source = (ROOT / "Flowlight/Analysis/RulePackCatalogVerifier.swift").read_text() + if OFFICIAL_PACK_URL not in verifier_source: + found.append("Flowlight/Analysis/RulePackCatalogVerifier.swift: fixed official feed URL is missing") + source_pages = [ROOT / "README.md", ROOT / "SECURITY.md", ROOT / "site/pages/docs.html", + ROOT / "site/pages/privacy.html", ROOT / "site/pages/threat-model.html"] + for path in source_pages: + text = path.read_text().lower() + if "official" not in text or not any(word in text for word in ("signed", "signature", "publisher")): + found.append(f"{path.relative_to(ROOT)}: authenticated official rule-pack consumer is not documented") + privacy_sources = [ROOT / "site/pages/privacy.html"] + [ + ROOT / f"site/pages/{lang}/privacy.html" for lang in LANGS + ] + for path in privacy_sources: + if "flowlight.xinbetween.com" not in path.read_text(): + found.append(f"{path.relative_to(ROOT)}: official rule-pack request is missing from privacy table") + docs_sources = [ROOT / "site/pages/docs.html"] + [ + ROOT / f"site/pages/{lang}/docs.html" for lang in LANGS + ] + for path in docs_sources: + if 'id="managed-packs"' not in path.read_text(): + found.append(f"{path.relative_to(ROOT)}: #managed-packs subscription section is missing") + stale_request_counts = ("Three requests", "Three smaller things", "Drei Anfragen", "Drei kleinere Dinge", + "Tres peticiones", "Tres cosas más pequeñas", "Trois requêtes", "Trois choses plus petites", + "Tre richieste", "Tre cose più piccole", "Três pedidos", "três coisas mais pequenas", + "リクエストは 3 つ", "요청은 세 가지", "떠나는 것이 세 가지", + "三个请求", "三個請求") + for path in [ROOT / "site/pages/docs.html", ROOT / "site/pages/threat-model.html"] + [ + ROOT / f"site/pages/{lang}/docs.html" for lang in LANGS + ] + [ROOT / f"site/pages/{lang}/threat-model.html" for lang in LANGS]: + if any(phrase.casefold() in path.read_text().casefold() for phrase in stale_request_counts): + found.append(f"{path.relative_to(ROOT)}: fixed outbound-request count is stale-prone") + # 1. A placeholder that didn't render. `{{t:nav.threat-model}}` printed itself on all 51 pages because the # pattern for {{t:}} didn't allow hyphens, and nothing noticed — it is valid HTML. for page in PAGES: @@ -140,8 +175,16 @@ def problems(): # newest version on the releases page. It reads as a promise until someone notices it is a memory. releases = (ROOT / "site/pages/releases.html").read_text() shipped = re.findall(r"

(\d+\.\d+(?:\.\d+)?)

", releases) + release_headers = re.findall(r"
(.*?)
", releases, re.S) + latest = [match.group(1) for header in release_headers + if 'class="tag">Latest
' in header + if (match := re.search(r"

(\d+\.\d+(?:\.\d+)?)

", header))] if shipped: newest = max(shipped, key=version_key) + if newest != APP_VERSION: + found.append(f"site/pages/releases.html: newest release {newest} does not match app {APP_VERSION}") + if latest != [APP_VERSION]: + found.append(f"site/pages/releases.html: expected one Latest tag on {APP_VERSION}, found {latest}") for page in PAGES: text = page.read_text() start = text.find('id="roadmap"') diff --git a/site/pages/de/docs.html b/site/pages/de/docs.html index 061836f8..c72e45fa 100644 --- a/site/pages/de/docs.html +++ b/site/pages/de/docs.html @@ -384,6 +384,11 @@

Regeln

Bildschirm nennt außerdem den ältesten Datenverkehr, den er sehen konnte: Eine Regel, die gegen zwei Stunden Aufzeichnung getestet wurde, ist gegen zwei Stunden Aufzeichnung getestet.

+

Verwaltete Werbe- und Tracker-Pakete

+

Regeln › Pakete enthält drei offizielle Hostnamenlisten: Werbung, Websiteübergreifendes Tracking und Datenschutz und Telemetrie. Sie sind von Anfang an sichtbar und standardmäßig alle deaktiviert. Zum Blockieren ist die Netzwerkerweiterung erforderlich; der nettop-Sampler kann nur beobachten.

+

Ausschließlich offizielle, signierte Updates. Flowlight prüft eine einzige festgelegte HTTPS-Quelle auf neuere, vom Herausgeber signierte Kataloge. Automatische Prüfungen sind standardmäßig aktiviert und können hier deaktiviert werden. Eine Signatur authentifiziert den Inhalt, aktiviert ihn aber nicht: Ein neues Paket bleibt deaktiviert, und bei geänderten Zielen wird ein aktiviertes Paket nicht mehr durchgesetzt, bis du die genaue neue Version geprüft und auf Sichern geklickt hast. Akzeptierte Inhalte können während eines Ausfalls als sichtbar veralteter letzter bekannter funktionsfähiger Stand erhalten bleiben. Dies sind Netzwerksperren auf Hostnamenebene: Sie können keine Lücken im DOM oder Layout, Browser-Overlays, skriptgesteuerten Pop-ups oder Fenster nativer Apps entfernen.

+

Wenn die HTTPS-Inspektion eine App durch Flowlights Proxy leitet, entscheidet er anhand der ursprünglichen App-Identität über gewöhnliche Zielregeln und aktivierte verwaltete Pakete, bevor eine Verbindung zum Ursprungsserver geöffnet wird. Bei angepinnten oder nie entschlüsselten CONNECT-Tunneln funktioniert das anhand des Hostnamens; Agenten-Erlaubnislisten bleiben auf den direkten Weg über die Netzwerkerweiterung beschränkt. Ist der Eigentümer oder Hostname unbekannt, wird die Verbindung im Zweifel zugelassen.

+

Erlaubnislisten

Wähle einen Agenten in KI-Agenten und sage über Erlaubnisliste in seinem Detail, wohin er sich verbinden darf. Trage Domains (Subdomains inklusive), IP-Adressen oder CIDR-Bereiche ein oder nimm eine Vorlage: GitHub, npm, PyPI, Homebrew, Docker Hub, Rust crates, Go-Module oder @@ -402,8 +407,7 @@

Verbindungen verweigern

Datenverkehr. Ein blockierter Mac ist schlimmer als ein blockierter Agent, im Zweifel wird die Verbindung also erlaubt und aufgezeichnet. Die Durchsetzung endet außerdem wenige Sekunden nachdem Flowlight nicht mehr läuft – eine Verweigerung, die niemand sehen kann, ist schlimmer als keine, und der Weg, sie aufzuheben, liegt in der App.

-

HTTPS-Inspektion und Blockieren lassen sich nicht kombinieren: Datenverkehr, der über Flowlights Proxy läuft, geht als Flowlights eigener hinaus und wird - nie verweigert.

+

Für eine App, die durch die HTTPS-Inspektion geleitet wird, entscheidet Flowlights Proxy gewöhnliche Zielregeln und aktivierte verwaltete Pakete anhand der ursprünglichen App-Identität, bevor er eine Verbindung zum Ursprungsserver öffnet. Bei angepinnten oder nie entschlüsselten CONNECT-Tunneln geschieht das anhand des Hostnamens. Agenten-Erlaubnislisten bleiben auf den direkten Weg über die Netzwerkerweiterung beschränkt; ist der Eigentümer oder Hostname unbekannt, wird die Verbindung im Zweifel zugelassen.

Schutzregeln für Agenten: ein Tool, einen Server oder eine Ressource blockieren

Welche Tools darf dieser Agent nutzen ist eine andere Frage als welche Hosts darf er erreichen, und hat darum ihren eigenen @@ -622,9 +626,7 @@

Fragen & Antworten

Es zeigt, welche Erweiterungen installiert sind, ob macOS Flowlights Filter wirklich gestartet hat und wie kürzlich überhaupt etwas aufgezeichnet wurde. Füge es in ein Issue ein.
Sendet Flowlight irgendetwas über mich irgendwohin?
-
Kein Konto, keine Telemetrie, keine Analyse, und deine Datenbank mit dem Datenverkehr wird nie hochgeladen. Drei Anfragen verlassen den Mac, alle auf der - Datenschutzseite aufgelistet: die tägliche Update-Prüfung und die optionale Abfrage zum Netzinhaber, beide abschaltbar, und, - solange Flowlight auf dem Sampler läuft, Reverse-DNS, für das es keinen Schalter gibt – ohne es könnte der Sampler die meisten Verbindungen nicht benennen.
+
Kein Konto, keine Telemetrie, keine Analyse, und deine Datenbank mit dem Datenverkehr wird nie hochgeladen. Die Datenschutzseite listet jede Anfrage auf: Release- und signierte Regelpaket-Prüfungen sowie die optionale Abfrage zum Netzinhaber haben Abschalter; Reverse-DNS läuft nur mit dem Sampler und hat keinen, weil er sonst die meisten Verbindungen nicht benennen könnte.
Macht es meinen Mac langsamer oder leert es den Akku?
Mit geschlossenem Fenster tut Flowlight fast nichts: Es hält die Raten in der Menüleiste und schreibt eine Zeile pro Sekunde. Die Netzwerkerweiterung kostet weniger als der Sampler als Rückfallebene, der jede Sekunde einen kurzlebigen nettop-Prozess startet.
diff --git a/site/pages/de/index.html b/site/pages/de/index.html index 2589a279..44c69ee2 100644 --- a/site/pages/de/index.html +++ b/site/pages/de/index.html @@ -167,6 +167,7 @@

Sieh, wie KI-Agenten das Netzwerk nutzen

Software, der du nicht vertraust – siehe das Bedrohungsmodell.

Cursor ⇥ raw.githubusercontent.com · 4-mal verweigert · wochentags 18:00–09:00

+
⛔ Opt-in

Verwaltete Werbe- und Tracker-Pakete

Drei offizielle Pakete sind integriert, sichtbar und standardmäßig deaktiviert. Flowlight prüft einen einzigen festgelegten HTTPS-Feed auf signierte Updates; neue oder geänderte Inhalte werden erst durchgesetzt, nachdem du genau diese Version geprüft und Sichern gedrückt hast. Verifizierte, akzeptierte Inhalte können bei einem Ausfall als sichtbar veralteter letzter bekannter funktionsfähiger Stand erhalten bleiben.

Sie blockieren nach Hostname und können daher keine Erstanbieterwerbung, Lücken im DOM oder Layout, Browser-Overlays, skriptgesteuerten Pop-ups oder Fenster nativer Apps entfernen.

⛔ Verweigert

Schutzregeln für die Tools eines Agenten

@@ -366,7 +367,7 @@

Ein Wächter, den du nicht bewachen musst

Bleibt auf deinem Mac

Eine lokale SQLite-Datei. Kein Konto, keine Cloud-Synchronisierung, keine Telemetrie.

Standardmäßig nur Metadaten

Hostnamen, Ports und Byte-Zahlen. HTTPS zu entschlüsseln ist ein eigener Modus, den du einschalten musst – und er bleibt auf deinem Mac.

Im Kernel gefiltert

Die Erfassung lässt nur DNS-Antworten und TLS-ClientHellos durch. Alles andere wird verworfen, bevor Flowlight es sieht.

-
Abfragen, die du steuerst

Um zu benennen, wem eine Adresse gehört, gehen nur öffentliche IPs ohne Hostnamen an Team Cymru – abschaltbar mit einem Klick. Die tägliche Update-Prüfung fragt GitHub nur nach der neuesten Version und lässt sich ebenfalls abschalten.

+
Abfragen, die du steuerst

Um zu benennen, wem eine Adresse gehört, gehen nur öffentliche IPs ohne Hostnamen an Team Cymru. Release- und signierte Regelpaket-Prüfungen senden Standard-HTTPS-Anfragen ohne Verkehrs- oder Paketeinstellungen. Jede Prüfung hat einen eigenen Abschalter.

diff --git a/site/pages/de/privacy.html b/site/pages/de/privacy.html index e6489ae6..2a69d53c 100644 --- a/site/pages/de/privacy.html +++ b/site/pages/de/privacy.html @@ -56,6 +56,7 @@

Netzwerkanfragen, die die App stellt

AnfrageGesendet anWas sie enthältWann Update-Prüfungapi.github.comEine normale HTTPS-Anfrage nach der neuesten Version; GitHub sieht deine IP-Adresse und den User-Agent Flowlight/<version>Beim Start und täglich, ab 0.1.2. Abschalten in Einstellungen › Allgemein Update-Downloadgithub.comDas Image und seine PrüfsummendateiNur wenn du auf „Herunterladen & öffnen“ klickst + Offizielle Regelpaket-Aktualisierungflowlight.xinbetween.comEine standardmäßige HTTPS-Anfrage an den festgelegten offiziellen Feed. Der Server sieht deine IP-Adresse, den User-Agent Flowlight/<version> und bedingte Cache-Validatoren; er erhält weder Datenverkehr noch Paketeinstellungen oder eine GerätekennungNach dem Start, sobald eine Prüfung fällig ist, und danach bei störungsfreiem Betrieb höchstens einmal täglich. Standardmäßig aktiviert; Ausschalter und manuelle Option Jetzt suchen unter Regeln › Pakete Reverse-DNSDer von dir eingerichtete DNS-ResolverEine Abfrage des registrierten Namens einer IP-Adresse, wenn kein Hostname gesehen wurdeNach Bedarf, im Erfassungsmodus des Samplers, der Standard ist, bis die Netzwerkerweiterung läuft. Kein Schalter zum Abschalten: ohne sie kann der Sampler die meisten Verbindungen nicht benennen. Die Netzwerkerweiterung benennt sie aus dem Datenverkehr selbst und macht diese Abfrage nicht Abfrage des NetzinhabersTeam Cymru, über DNSÖffentliche IP-Adressen ohne bekannten Hostnamen. Private und reservierte Adressen verlassen deinen Mac nieStandardmäßig ein. Abschalten in Erfassung › Netzinhaber bestimmen Fragen mit einem gehosteten ModellDer Anbieter, den du wählst: Anthropic, OpenAI, Google Gemini oder ein kompatibler EndpunktDeine Frage und die Ergebnisse der nur lesenden Flowlight-Abfragen, die das Modell ausgewählt hat. Die Verkehrsdatenbank selbst wird nicht hochgeladenNur wenn du einen gehosteten Anbieter wählst und eine Frage abschickst. Der Bildschirm Fragen zeigt die gesendeten Anfragen diff --git a/site/pages/de/threat-model.html b/site/pages/de/threat-model.html index a6459d2c..8b21c993 100644 --- a/site/pages/de/threat-model.html +++ b/site/pages/de/threat-model.html @@ -87,38 +87,35 @@

Was der Inspektion entgeht

welches von beidem gilt.

Was Blockieren ist und was nicht

-

Eine Regel, die Verbindungen verweigert, wird von der Netzwerkerweiterung ausgeführt, im Weg der Verbindung, bevor sie zustande kommt. Eine - Regel, die eine URL nennt, führt stattdessen der Inspektions-Proxy aus, der die Anfrage mit einem Status beantwortet, den die App lesen kann. - Das sind die einzigen zwei Teile von Flowlight, die handeln statt zu berichten – der nettop-Sampler kann überhaupt nichts verweigern, auf einem - Mac, der mit dem Sampler erfasst, wird also keine Regel durchgesetzt, und Flowlight sagt das, statt einen Schalter anzubieten, der nichts tut.

+

Eine Regel, die Verbindungen verweigert, wird normalerweise von der Netzwerkerweiterung ausgeführt, im Weg der Verbindung, bevor sie zustande kommt. + Bei Datenverkehr, der durch die HTTPS-Inspektion geleitet wird, wendet der lokale Proxy Zielregeln und aktivierte verwaltete Pakete auf den + ursprünglichen Client an, bevor er die Verbindung zum Ursprungsserver öffnet; eine Regel, die eine URL nennt, führt er nach der Entschlüsselung + ebenfalls dort aus und antwortet mit einem Status, den die App lesen kann. Das sind die einzigen zwei Teile von Flowlight, die handeln statt zu + berichten – der nettop-Sampler kann überhaupt nichts verweigern, auf einem Mac, der mit dem Sampler erfasst, wird also keine Regel durchgesetzt, + und Flowlight sagt das, statt einen Schalter anzubieten, der nichts tut.

Verhindern ist es trotzdem nicht. Ein Agent, der eine Shell ausführen kann, kann von Hand tun, was ein verweigertes Tool getan hätte. Eine Verweigerung, die du mit einem Klick aufheben kannst, ist eine Verweigerung, durch die der nächste Versuch eines Agenten hindurchgehen kann. Lokaler Datenverkehr, DNS, Apples eigene Dienste und Flowlights eigene Verbindungen werden nie verweigert, denn ein blockierter Mac ist ein schlechteres Ergebnis als ein blockierter Agent. Und die Durchsetzung endet wenige Sekunden nachdem Flowlight nicht mehr läuft: Eine Verweigerung, die niemand sehen kann, ist schlimmer als keine.

-

Inspektion und Verweigerung lassen sich nicht verbinden. Datenverkehr, der über Flowlights eigenen Proxy geleitet wird, - verlässt den Mac als Flowlights Datenverkehr, und Flowlight verweigert sich selbst nie – eine App, deren Anfragen du liest, ist also eine App, - deren Verbindungen Regeln nicht mehr stoppen. Die Inspektion einzuschalten, um mehr zu sehen, kostet dich die Durchsetzung, und auf dem - Bildschirm stand das vorher nirgends.

+

Die Inspektion erhält die Verweigerung nach Hostnamen, mit einer Grenze. Datenverkehr, der durch Flowlights Proxy geleitet wird, + verlässt den Mac als ausgenommener Flowlight-Datenverkehr. Deshalb entscheidet der Proxy zuerst anhand der ursprünglichen App-Identität über + gewöhnliche Zielregeln und aktivierte verwaltete Pakete. Das funktioniert auch bei angepinnten oder nie entschlüsselten Tunneln, weil CONNECT den + Host nennt. Agenten-Erlaubnislisten bleiben eine Entscheidung der Netzwerkerweiterung und gelten nicht für diesen über den Proxy geleiteten Weg + zum Ursprungsserver; Datenverkehr ohne vertrauenswürdigen Eigentümer oder Hostnamen wird im Zweifel zugelassen, statt unsichtbar verweigert zu werden.

Was Flowlight selbst aufbewahrt

Metadaten zum Datenverkehr in einer lokalen SQLite-Datenbank, mit veröffentlichten Aufbewahrungsfristen. Bei eingeschalteter HTTPS-Inspektion außerdem Anfrage- und Antwort-Bodys für drei Tage – das Sensibelste, was es überhaupt aufbewahrt. Header mit Zugangsdaten werden nach Name und nach Wortbestandteil unlesbar gemacht, was eine Heuristik ist und keine Garantie; ein Header, der ein Geheimnis unter einem Namen trägt, den nichts erkennt, kann aufgezeichnet werden.

-

Die Datenbank mit dem Datenverkehr wird nie hochgeladen. Drei kleinere Dinge verlassen den Mac, ohne dass jemand danach fragt, alle drei auf der - Datenschutzseite benannt: die Update-Prüfung, die GitHub beim Start und täglich nach der neuesten Version - fragt; die Abfrage zum Netzinhaber, die Team Cymru nach öffentlichen Adressen ohne Hostnamen fragt; und, solange Flowlight Zähler liest - statt seine Erweiterung zu betreiben, eine Reverse-DNS-Abfrage der Adressen, zu denen es keinen Namen gesehen hat. Die ersten beiden haben - Schalter zum Abschalten. Die dritte nicht, denn auf dem Sampler ist sie fast der einzige Weg, auf dem eine Verbindung überhaupt zu einem - Namen kommt – sie geht an den Resolver, den der Mac schon benutzt, und sie hört auf, sobald die Erweiterung übernimmt und Verbindungen aus dem - Datenverkehr selbst benennt. Fragen mit einem gehosteten Modell sendet weit mehr als alle drei und ist das eine, das aus bleibt, bis du es - einrichtest.

+

Die Datenbank mit dem Datenverkehr wird nie hochgeladen. Die Datenschutzseite nennt jede ausgehende Anfrage: Release- und signierte Regelpaket-Prüfungen, die optionale Abfrage zum Netzinhaber und Reverse-DNS im Sampler. Die ersten drei haben Abschalter; Reverse-DNS nicht, weil der Sampler sonst die meisten Verbindungen nicht benennen könnte. Fragen mit einem gehosteten Modell senden weit mehr und bleiben aus, bis du sie einrichtest.

Updates

Versionen sind mit einer Developer ID signiert und notarisiert. Die Updatefunktion verlangt eine veröffentlichte Prüfsumme und verweigert einen Download ohne sie, und prüft dann vor der Installation, dass die heruntergeladene App vom selben Team signiert ist wie die laufende Kopie. Ein Update, das jemand anderes signiert hat, wird verweigert, auch wenn es sonst gültig ist.

+

Offizielle Regelpakete verwenden eine separate zweckgebundene Signatur und eine einzige festgelegte HTTPS-Quelle. Die App verwaltet unabhängig davon die höchste Sequenznummer, verifiziert beim Start die exakten zwischengespeicherten Bytes erneut, weist Rollbacks und widersprüchliche Veröffentlichungen zurück und behandelt eine Herausgebersignatur niemals als Erlaubnis, geänderte Inhalte durchzusetzen. Neue oder geänderte Pakete erfordern eine ausdrückliche Prüfung des exakten Inhalts und Sichern; veraltete akzeptierte Inhalte werden als letzter bekannter funktionsfähiger Stand gekennzeichnet, während ein Vertrauensentzug oder beschädigte Bytes sicherheitshalber zur Sperrung führen.

Eine Schwachstelle melden

Nutze GitHubs private Schwachstellenmeldung oder schreib an diff --git a/site/pages/docs.html b/site/pages/docs.html index 9d9dc93d..6b41be8c 100644 --- a/site/pages/docs.html +++ b/site/pages/docs.html @@ -390,16 +390,22 @@

Rules

of them says the rule is safe. The screen also names the oldest traffic it could see: a rule tested against two hours of history has been tested against two hours of history.

-

Managed ad and tracker packs

+

Managed ad and tracker packs

Rules › Packs contains three official lists built into Flowlight: Advertising covers known advertising delivery and auction hosts; Cross-site tracking covers common analytics, session replay and cross-site measurement hosts; and Privacy and telemetry covers crash reporting, install attribution, and - audited Apple advertising and analytics hosts. The packs are visible from the start and all are off by default. - Nothing changes on an update until you choose a pack and enable it.

+ audited Apple advertising and analytics hosts. The packs are visible from the start and all are off by default.

+

Official-only, signed updates. Flowlight checks one fixed HTTPS source for newer publisher-signed catalogs. + Automatic checks are on by default and can be switched off here. A signature authenticates content but does not enable it: + a new pack stays off, and changed destinations remove an enabled pack from enforcement until you review the exact new version + and press Save. If content changes while its editor is open, the stale Save is refused. An accepted catalog may remain available + as visibly stale last-known-good during an outage; rollback, equivocation, revoked trust, or corrupt cached bytes fail closed.

Each pack has its own scope. Apply it globally to every app, or only to selected applications. Blocking needs - the Network Extension: the nettop sampler can record traffic, but it cannot sit in a connection's path and refuse it. Essential - system traffic remains allowed — including the services that carry Apple accounts, push notifications, software updates, - certificate checks and iCloud — even when a pack applies globally.

+ the Network Extension: the nettop sampler can record traffic, but it cannot sit in a connection's path and refuse it. When HTTPS + inspection routes an app through Flowlight's proxy, enabled packs and ordinary destination rules are decided against that original + app before any origin connection opens. This also covers pinned and never-decrypted tunnels by hostname; agent allowlists remain + limited to the direct Network Extension path. Essential system traffic remains allowed — including the services that carry Apple + accounts, push notifications, software updates, certificate checks and iCloud — even when a pack applies globally.

Preview before enforcing. Test against recent traffic replays the proposed pack and scope against up to seven days of retained traffic. It reports only decisions that would actually change after ordinary rules, the global pause, existing packs and agent policies are taken into account: the affected apps and destinations, connection counts, and bytes. @@ -656,9 +662,9 @@

Questions & answers

It shows which extensions are installed, whether macOS actually started Flowlight's filter, and how recently anything was recorded. Paste it into an issue.
Does Flowlight send anything about me anywhere?
-
No account, no telemetry, no analytics, and your traffic database is never uploaded. Three requests do leave the Mac, all listed on the - privacy page: the daily update check and the optional network-owner lookup, both with off switches, and - reverse DNS while Flowlight is on the sampler, which has none — without it the sampler could not name most connections.
+
No account, no telemetry, no analytics, and your traffic database is never uploaded. Four requests can leave the Mac, all listed on the + privacy page: release and signed rule-pack checks plus the optional network-owner lookup, all with off + switches, and reverse DNS while Flowlight is on the sampler, which has none — without it the sampler could not name most connections.
Will it slow my Mac down or drain the battery?
With the window closed Flowlight does almost nothing: it keeps the menu bar rates and writes one row a second. The Network Extension costs less than the fallback sampler, which starts a short-lived nettop process every second.
diff --git a/site/pages/es/docs.html b/site/pages/es/docs.html index 32c70385..c4a06c08 100644 --- a/site/pages/es/docs.html +++ b/site/pages/es/docs.html @@ -379,6 +379,11 @@

Reglas

segura. La pantalla nombra también el tráfico más antiguo que pudo ver: una regla probada con dos horas de historial se ha probado con dos horas de historial.

+

Paquetes gestionados de publicidad y rastreadores

+

Reglas › Paquetes contiene tres listas oficiales de nombres de host: Publicidad, Seguimiento entre sitios y Privacidad y telemetría. Son visibles desde el principio y todas están desactivadas de forma predeterminada. El bloqueo requiere la Extensión de red; el muestreador nettop solo puede observar.

+

Solo actualizaciones oficiales y firmadas. Flowlight consulta una única fuente HTTPS fija para buscar catálogos más recientes firmados por el editor. Las comprobaciones automáticas están activadas de forma predeterminada y pueden desactivarse aquí. Una firma autentica el contenido, pero no lo activa: un paquete nuevo permanece desactivado y, si cambian los destinos, un paquete activado deja de aplicarse hasta que revises la nueva versión exacta y pulses Guardar. Durante una interrupción, el contenido aceptado puede conservarse como la última versión válida conocida, marcada visiblemente como obsoleta. Estos son bloqueos de red a nivel de nombre de host: no pueden eliminar huecos del DOM o del diseño, superposiciones del navegador, ventanas emergentes generadas por scripts ni ventanas de aplicaciones nativas.

+

Cuando la inspección HTTPS encamina una app por el proxy de Flowlight, este decide las reglas de destino normales y los paquetes gestionados activados usando la identidad original de la app antes de abrir ninguna conexión con el servidor de origen. En los túneles CONNECT con certificado fijado o que nunca se descifran, esto funciona por nombre de host; las listas de permitidos de agentes siguen limitadas al camino directo por la Extensión de red. Si no se conoce el propietario o el nombre de host, la conexión se permite.

+

Listas de permitidos

Selecciona un agente en Agentes de IA y usa Lista de permitidos en su detalle para indicar adónde puede conectar. Introduce dominios (subdominios incluidos), direcciones IP o rangos CIDR, o elige un preajuste: GitHub, npm, PyPI, Homebrew, Docker Hub, Rust crates, Go modules o @@ -397,8 +402,7 @@

Rechazar conexiones

de Flowlight. Un Mac bloqueado es peor resultado que un agente bloqueado, así que ante la duda la conexión se permite y se registra. La aplicación de las reglas también caduca unos segundos después de que Flowlight deje de ejecutarse: un rechazo que nadie puede ver es peor que ninguno, y la forma de deshacer uno está en la app.

-

La inspección HTTPS y el bloqueo no se combinan: el tráfico encaminado por el proxy de Flowlight sale como tráfico propio de Flowlight, que - nunca se rechaza.

+

Para una app encaminada por la inspección HTTPS, el proxy de Flowlight decide las reglas de destino normales y los paquetes gestionados activados usando la identidad original de la app antes de abrir ninguna conexión con el servidor de origen. En los túneles CONNECT con certificado fijado o que nunca se descifran, lo hace por nombre de host. Las listas de permitidos de agentes siguen limitadas al camino directo por la Extensión de red; si no se conoce el propietario o el nombre de host, la conexión se permite.

Salvaguardas de agente: bloquear una herramienta, un servidor o un recurso

Qué herramientas puede usar este agente es una pregunta distinta de a qué hosts puede llegar, así que tiene su propia @@ -616,9 +620,7 @@

Preguntas y respuestas

Muestra qué extensiones están instaladas, si macOS arrancó de verdad el filtro de Flowlight y cuándo se registró algo por última vez. Pégalo en una incidencia.
¿Flowlight envía algo sobre mí a alguna parte?
-
Sin cuenta, sin telemetría, sin analítica, y tu base de datos de tráfico nunca se sube. Tres peticiones sí salen del Mac, todas listadas en la - página de privacidad: la comprobación diaria de actualizaciones y la consulta opcional del propietario de la red, las dos con - interruptor para desactivarlas, y el DNS inverso mientras Flowlight está en el muestreador, que no lo tiene — sin él el muestreador no podría nombrar la mayoría de las conexiones.
+
Sin cuenta, sin telemetría, sin analítica, y tu base de datos de tráfico nunca se sube. La página de privacidad enumera cada petición: las comprobaciones de versión y de paquetes de reglas firmados, además de la consulta opcional del propietario de red, tienen interruptores; el DNS inverso solo funciona con el muestreador y no tiene uno porque sin él no podría nombrar la mayoría de las conexiones.
¿Ralentizará mi Mac o gastará la batería?
Con la ventana cerrada, Flowlight casi no hace nada: mantiene las tasas de la barra de menús y escribe una fila por segundo. La extensión de red cuesta menos que el muestreador de reserva, que arranca un proceso nettop efímero cada segundo.
diff --git a/site/pages/es/index.html b/site/pages/es/index.html index d94b5d77..fc8759a8 100644 --- a/site/pages/es/index.html +++ b/site/pages/es/index.html @@ -167,6 +167,7 @@

Vigila cómo usan la red los agentes de IA

software en el que no — mira el modelo de amenazas.

Cursor ⇥ raw.githubusercontent.com · rechazada 4 veces · de lunes a viernes 18:00–09:00

+
⛔ Previa activación

Paquetes gestionados de publicidad y rastreadores

Hay tres paquetes oficiales integrados, visibles y desactivados de forma predeterminada. Flowlight consulta un único feed HTTPS fijo para buscar actualizaciones firmadas; el contenido nuevo o modificado no se aplica hasta que revises esa versión exacta y pulses Guardar. Durante una interrupción, el contenido verificado y aceptado puede conservarse como la última versión válida conocida, marcada visiblemente como obsoleta.

Bloquean por nombre de host, por lo que no pueden eliminar anuncios propios del sitio, huecos del DOM o del diseño, superposiciones del navegador, ventanas emergentes generadas por scripts ni ventanas de aplicaciones nativas.

⛔ Rechaza

Salvaguardas sobre las herramientas de un agente

@@ -366,7 +367,7 @@

Un monitor que no tienes que vigilar

Se queda en tu Mac

Un único archivo SQLite local. Sin cuenta, sin sincronización en la nube, sin telemetría.

Metadatos por defecto

Nombres de host, puertos y bytes. Descifrar HTTPS es un modo aparte que tienes que activar, y también se queda en tu Mac.

Filtrado en el núcleo

La captura solo deja pasar las respuestas DNS y los ClientHello de TLS. Todo lo demás se descarta antes de que Flowlight lo vea.

-
Consultas que tú controlas

Para saber de quién es una dirección solo se envían a Team Cymru las IP públicas sin nombre de host, y se desactiva con un clic. La comprobación diaria de actualizaciones solo pregunta a GitHub por la última versión, y también puede desactivarse.

+
Consultas que tú controlas

Para saber de quién es una dirección solo se envían a Team Cymru las IP públicas sin nombre de host. Las comprobaciones de versiones y de paquetes de reglas firmados envían solicitudes HTTPS estándar sin tráfico ni configuración de paquetes. Cada una tiene su propio interruptor.

diff --git a/site/pages/es/privacy.html b/site/pages/es/privacy.html index 84271b37..f4f16c74 100644 --- a/site/pages/es/privacy.html +++ b/site/pages/es/privacy.html @@ -56,6 +56,7 @@

Peticiones de red que hace la app

PeticiónEnviada aQué contieneCuándo Comprobación de actualizacionesapi.github.comUna petición HTTPS normal de la última versión; GitHub ve tu dirección IP y el agente de usuario Flowlight/<version>Al arrancar y a diario, desde la 0.1.2. Interruptor para desactivarla en Ajustes › General Descarga de la actualizacióngithub.comLa imagen de disco y su archivo de suma de verificaciónSolo cuando pulsas Descargar y abrir + Actualización oficial del paquete de reglasflowlight.xinbetween.comUna solicitud HTTPS estándar al feed oficial fijo. El servidor ve tu dirección IP, el agente de usuario Flowlight/<version> y los validadores condicionales de caché; no recibe tráfico, ajustes de paquetes ni ningún identificador del dispositivoDespués del inicio, cuando corresponda, y luego como máximo una vez al día mientras todo funcione correctamente. Activada de forma predeterminada; interruptor para desactivarla y opción manual Buscar ahora en Reglas › Paquetes DNS inversoEl servidor DNS que tengas configuradoUna consulta del nombre registrado de una dirección IP, cuando no se ha visto ningún nombre de hostSegún haga falta, en el modo de captura del muestreador, que es el predeterminado hasta que la extensión de red esté en marcha. Sin interruptor para desactivarla: sin ella el muestreador no puede nombrar la mayoría de las conexiones. La extensión de red las nombra a partir del propio tráfico y no hace esta consulta Consulta del propietario de la redTeam Cymru, por DNSDirecciones IP públicas sin nombre de host conocido. Las direcciones privadas y reservadas no salen nunca de tu MacActivada por defecto. Interruptor para desactivarla en Captura › Identificar a los propietarios de la red Preguntar con un modelo alojadoEl proveedor que elijas: Anthropic, OpenAI, Google Gemini o un endpoint compatibleTu pregunta y los resultados que devuelvan las consultas de solo lectura de Flowlight que elija el modelo. La base de datos de tráfico en sí no se subeSolo cuando eliges un proveedor alojado y envías una pregunta. La pantalla Preguntar muestra las peticiones transmitidas diff --git a/site/pages/es/threat-model.html b/site/pages/es/threat-model.html index fbcbcc82..4a95d299 100644 --- a/site/pages/es/threat-model.html +++ b/site/pages/es/threat-model.html @@ -84,36 +84,34 @@

Lo que la inspección se pierde

cosas se aplica, app por app.

Qué es bloquear, y qué no

-

De una regla que rechaza conexiones se encarga la extensión de red, en el camino, antes de que la conexión se haga. De una regla que nombra - una URL se encarga en cambio el proxy de inspección, que responde a la petición con un estado que la app puede leer. Esas son las dos únicas - partes de Flowlight que actúan en lugar de informar — el muestreador nettop no puede rechazar nada en absoluto, así que en un Mac que captura - con el muestreador no se aplica ninguna regla, y Flowlight lo dice en lugar de ofrecer un interruptor que no hace nada.

+

De una regla que rechaza conexiones se encarga normalmente la extensión de red, en el camino, antes de que la conexión se haga. Para el tráfico + encaminado por la inspección HTTPS, el proxy local aplica las reglas de destino y los paquetes gestionados activados al cliente original antes + de abrir la conexión con el servidor de origen; una regla que nombra una URL también se aplica allí, después del descifrado, y responde con un + estado que la app puede leer. Esas son las dos únicas partes de Flowlight que actúan en lugar de informar — el muestreador nettop no puede + rechazar nada en absoluto, así que en un Mac que captura con el muestreador no se aplica ninguna regla, y Flowlight lo dice en lugar de ofrecer + un interruptor que no hace nada.

Aun así no es impedir. Un agente que puede ejecutar un shell puede hacer a mano lo que habría hecho la herramienta rechazada. Un rechazo que puedes deshacer con un clic es un rechazo por el que el siguiente intento del agente puede pasar. El tráfico local, el DNS, los servicios de Apple y las propias conexiones de Flowlight no se rechazan nunca, porque un Mac bloqueado es peor resultado que un agente bloqueado. Y la aplicación de las reglas caduca unos segundos después de que Flowlight deje de ejecutarse: un rechazo que nadie puede ver es peor que ninguno.

-

La inspección y el rechazo no se combinan. El tráfico encaminado por el propio proxy de Flowlight sale del Mac como tráfico - de Flowlight, y Flowlight no se rechaza nunca a sí mismo — así que una app cuyas peticiones estás leyendo es una app cuyas conexiones las - reglas ya no detienen. Activar la inspección para ver más te cuesta la aplicación de las reglas, y hasta ahora nada en la pantalla lo decía.

+

La inspección conserva el rechazo por nombre de host, con un límite. El tráfico encaminado por el proxy de Flowlight sale del + Mac como tráfico exento de Flowlight, así que el proxy decide primero las reglas de destino normales y los paquetes gestionados activados usando + la identidad original de la app. Puede hacerlo incluso con túneles que fijan certificados o que nunca se descifran, porque CONNECT nombra el host. + Las listas de permitidos de agentes siguen siendo una decisión de la Extensión de red y no se aplican a ese tramo hacia el servidor de origen a + través del proxy; el tráfico sin un propietario fiable o sin nombre de host se permite en vez de rechazarse sin que nadie lo vea.

Lo que guarda Flowlight mismo

Metadatos de tráfico en una base de datos SQLite local, con una retención publicada. Con la inspección HTTPS activada guarda además los cuerpos de las peticiones y las respuestas durante tres días: lo más delicado que llega a conservar. Las cabeceras con credenciales se censuran por nombre y por palabra, lo cual es una heurística y no una garantía; una cabecera que lleve un secreto con un nombre que nada reconoce puede quedar registrada.

-

La base de datos de tráfico nunca se sube. Tres cosas más pequeñas sí salen del Mac sin que nadie las pida, las tres nombradas en la - página de privacidad: la comprobación de actualizaciones, que pide a GitHub la última versión al arrancar y a - diario; la consulta del propietario de la red, que pregunta a Team Cymru por direcciones públicas que no tienen nombre de host; y, mientras - Flowlight lee contadores en vez de ejecutar su extensión, una consulta de DNS inverso de las direcciones para las que no vio ningún nombre. - Las dos primeras se pueden desactivar. La tercera no, porque en el muestreador es casi la única manera en la que una conexión llega a tener - nombre: va al servidor DNS que el Mac ya usa, y se detiene en cuanto la extensión toma el relevo y nombra las conexiones a partir del propio - tráfico. Preguntar con un modelo alojado envía mucho más que cualquiera de ellas, y es lo único que está desactivado hasta que lo - configuras.

+

La base de datos de tráfico nunca se sube. La página de privacidad enumera cada petición saliente: comprobaciones de versión y de paquetes de reglas firmados, la consulta opcional del propietario de red y DNS inverso en el muestreador. Las tres primeras tienen interruptores; el DNS inverso no, porque sin él el muestreador no podría nombrar la mayoría de las conexiones. Preguntar con un modelo alojado envía mucho más y permanece desactivado hasta que lo configuras.

Actualizaciones

Las versiones se firman con un Developer ID y se notarizan. El actualizador exige una suma de comprobación publicada y rechaza una descarga que no la tenga; después comprueba que la app descargada esté firmada por el mismo equipo que la copia en ejecución antes de instalarla. Una actualización firmada por otra persona se rechaza aunque por lo demás sea válida.

+

Los paquetes de reglas oficiales usan una firma independiente limitada a este fin y una única fuente HTTPS fija. La aplicación mantiene por separado una marca de la secuencia más alta, vuelve a verificar al iniciarse los bytes exactos almacenados en caché, rechaza las regresiones y las publicaciones contradictorias, y nunca considera la firma del editor como permiso para aplicar contenido modificado. Los paquetes nuevos o modificados requieren la revisión explícita del contenido exacto y Guardar; el contenido obsoleto ya aceptado se etiqueta como la última versión válida conocida, mientras que la revocación de la confianza o los bytes dañados provocan un rechazo seguro.

Informar de una vulnerabilidad

Usa el informe privado de vulnerabilidades de GitHub, o escribe a diff --git a/site/pages/fr/docs.html b/site/pages/fr/docs.html index 0fbb2885..124ad2d4 100644 --- a/site/pages/fr/docs.html +++ b/site/pages/fr/docs.html @@ -383,6 +383,11 @@

Règles

règle est sans danger. L’écran nomme aussi le trafic le plus ancien qu’il a pu voir : une règle testée sur deux heures d’historique a été testée sur deux heures d’historique.

+

Packs gérés de publicité et de traqueurs

+

Règles › Packs contient trois listes officielles de noms d’hôte : Publicité, Suivi intersites et Confidentialité et télémétrie. Elles sont visibles dès le départ et toutes désactivées par défaut. Le blocage nécessite l’Extension réseau ; l’échantillonneur nettop ne peut qu’observer.

+

Uniquement des mises à jour officielles et signées. Flowlight consulte une source HTTPS fixe unique pour rechercher des catalogues plus récents signés par l’éditeur. Les vérifications automatiques sont activées par défaut et peuvent être désactivées ici. Une signature authentifie le contenu, mais ne l’active pas : un nouveau pack reste désactivé et, si les destinations changent, l’application d’un pack activé est suspendue jusqu’à ce que vous examiniez la nouvelle version exacte et appuyiez sur Enregistrer. Pendant une panne, le contenu accepté peut rester disponible comme dernière version valide connue, visiblement signalée comme obsolète. Il s’agit de blocages réseau au niveau des noms d’hôte : ils ne peuvent pas supprimer les vides dans le DOM ou la mise en page, les superpositions du navigateur, les fenêtres contextuelles générées par des scripts ni les fenêtres d’applications natives.

+

Quand l’inspection HTTPS achemine une app par le proxy de Flowlight, celui-ci décide des règles de destination ordinaires et des packs gérés activés d’après l’identité d’origine de l’app, avant d’ouvrir toute connexion au serveur d’origine. Pour les tunnels CONNECT épinglés ou jamais déchiffrés, cela fonctionne par nom d’hôte ; les listes d’autorisation des agents restent limitées au chemin direct par l’Extension réseau. Si le propriétaire ou le nom d’hôte est inconnu, la connexion est autorisée.

+

Listes d’autorisation

Sélectionnez un agent dans Agents IA et utilisez Liste d’autorisation dans son détail pour dire où il peut se connecter. Saisissez des domaines (sous-domaines inclus), des adresses IP ou des plages CIDR, ou choisissez un préréglage : GitHub, npm, PyPI, Homebrew, Docker Hub, crates Rust, modules Go ou @@ -401,8 +406,7 @@

Refuser des connexions

Flowlight lui-même. Un Mac bloqué est un pire résultat qu’un agent bloqué : en cas de doute, la connexion est donc autorisée et enregistrée. L’application des refus cesse aussi quelques secondes après l’arrêt de Flowlight — un refus que personne ne peut voir est pire que pas de refus, et le moyen de l’annuler vit dans l’app.

-

L’inspection HTTPS et le blocage ne se combinent pas : le trafic acheminé par le proxy de Flowlight sort comme trafic de Flowlight, qui n’est - jamais refusé.

+

Pour une app acheminée par l’inspection HTTPS, le proxy de Flowlight décide des règles de destination ordinaires et des packs gérés activés d’après l’identité d’origine de l’app, avant d’ouvrir toute connexion au serveur d’origine. Pour les tunnels CONNECT épinglés ou jamais déchiffrés, il le fait par nom d’hôte. Les listes d’autorisation des agents restent limitées au chemin direct par l’Extension réseau ; si le propriétaire ou le nom d’hôte est inconnu, la connexion est autorisée.

Garde-fous des agents : bloquer un outil, un serveur ou une ressource

Quels outils cet agent peut-il utiliser est une question différente de quels hôtes peut-il joindre : elle a donc sa propre @@ -622,9 +626,7 @@

Questions & réponses

Cela montre quelles extensions sont installées, si macOS a réellement démarré le filtre de Flowlight, et à quand remonte le dernier enregistrement. Collez le résultat dans un ticket.
Flowlight envoie-t-il quoi que ce soit à mon sujet, où que ce soit ?
-
Ni compte, ni télémétrie, ni analytique, et votre base de données de trafic n’est jamais téléversée. Trois requêtes quittent bien le Mac, toutes listées sur la - page Confidentialité : la recherche quotidienne de mise à jour et la consultation facultative du propriétaire de réseau, toutes - deux avec un interrupteur d’arrêt, et le DNS inverse tant que Flowlight tourne sur l’échantillonneur, qui n’en a pas — sans lui, l’échantillonneur ne pourrait pas nommer la plupart des connexions.
+
Ni compte, ni télémétrie, ni analytique, et votre base de données de trafic n’est jamais téléversée. La page Confidentialité répertorie chaque requête : les vérifications de version et de packs de règles signés, ainsi que la consultation facultative du propriétaire du réseau, ont des interrupteurs ; le DNS inverse ne fonctionne qu’avec l’échantillonneur et n’en a pas, car sans lui la plupart des connexions resteraient sans nom.
Va-t-il ralentir mon Mac ou vider la batterie ?
Fenêtre fermée, Flowlight ne fait presque rien : il tient à jour les débits de la barre des menus et écrit une ligne par seconde. L’extension réseau coûte moins que l’échantillonneur de secours, qui lance un processus nettop éphémère chaque seconde.
diff --git a/site/pages/fr/index.html b/site/pages/fr/index.html index 08a51af2..69bd5c77 100644 --- a/site/pages/fr/index.html +++ b/site/pages/fr/index.html @@ -167,6 +167,7 @@

Voyez comment les agents IA se servent du réseau

une barrière contre ceux auxquels vous n’en faites pas — voyez le modèle de menaces.

Cursor ⇥ raw.githubusercontent.com · refusé 4 fois · en semaine 18:00–09:00

+
⛔ Sur activation

Packs gérés de publicité et de traqueurs

Trois packs officiels sont intégrés, visibles et désactivés par défaut. Flowlight consulte un flux HTTPS fixe unique pour rechercher les mises à jour signées ; tout contenu nouveau ou modifié reste inappliqué jusqu’à ce que vous examiniez cette version exacte et appuyiez sur Enregistrer. Pendant une panne, le contenu vérifié et accepté peut être conservé comme dernière version valide connue, visiblement signalée comme obsolète.

Ils bloquent par nom d’hôte et ne peuvent donc pas supprimer les publicités internes au site, les vides dans le DOM ou la mise en page, les superpositions du navigateur, les fenêtres contextuelles générées par des scripts ni les fenêtres d’applications natives.

⛔ Refuse

Des garde-fous sur les outils d’un agent

@@ -366,7 +367,7 @@

Un moniteur que vous n’avez pas à surveiller

Reste sur votre Mac

Un seul fichier SQLite local. Pas de compte, pas de synchronisation cloud, pas de télémétrie.

Des métadonnées par défaut

Noms d’hôte, ports et nombre d’octets. Déchiffrer le HTTPS est un mode distinct que vous devez activer, et qui reste sur votre Mac.

Filtré dans le noyau

La capture ne laisse passer que les réponses DNS et les ClientHello TLS. Tout le reste est écarté avant que Flowlight le voie.

-
Des recherches que vous maîtrisez

Nommer le propriétaire d’une adresse n’envoie à Team Cymru que des IP publiques sans nom d’hôte, et se désactive en un clic. La recherche quotidienne de mise à jour ne demande à GitHub que la dernière version, et peut aussi être désactivée.

+
Des recherches que vous maîtrisez

Nommer le propriétaire d’une adresse n’envoie à Team Cymru que les IP publiques sans nom d’hôte. Les vérifications de version et de packs de règles signés envoient des requêtes HTTPS standard sans trafic ni réglages de packs. Chacune dispose de son propre interrupteur.

diff --git a/site/pages/fr/privacy.html b/site/pages/fr/privacy.html index 8f26460c..b338aae8 100644 --- a/site/pages/fr/privacy.html +++ b/site/pages/fr/privacy.html @@ -56,6 +56,7 @@

Les requêtes réseau que fait l’app

RequêteEnvoyée àCe qu’elle contientQuand Recherche de mise à jourapi.github.comUne requête HTTPS standard pour la dernière version ; GitHub voit votre adresse IP et l’agent utilisateur Flowlight/<version>Au lancement et une fois par jour, depuis 0.1.2. Interrupteur d’arrêt dans Réglages › Général Téléchargement de mise à jourgithub.comL’image disque et son fichier de somme de contrôleSeulement quand vous cliquez sur Télécharger & ouvrir + Mise à jour du pack de règles officielflowlight.xinbetween.comUne requête HTTPS standard vers le flux officiel fixe. Le serveur voit votre adresse IP, l’agent utilisateur Flowlight/<version> et les validateurs conditionnels du cache ; il ne reçoit ni trafic, ni réglages des packs, ni identifiant d’appareilAprès le démarrage, lorsqu’une vérification est due, puis au maximum une fois par jour tant que le service fonctionne normalement. Activée par défaut ; interrupteur de désactivation et option manuelle Rechercher maintenant dans Règles › Packs DNS inverseLe résolveur DNS que vous avez configuréUne recherche du nom enregistré d’une adresse IP, quand aucun nom d’hôte n’a été observéAu besoin, dans le mode de capture par échantillonnage, qui est celui par défaut jusqu’à ce que l’extension réseau tourne. Pas d’interrupteur d’arrêt : sans elle, l’échantillonneur ne peut pas nommer la plupart des connexions. L’extension réseau les nomme à partir du trafic lui-même et ne fait pas cette recherche Recherche du propriétaire de réseauTeam Cymru, par DNSDes adresses IP publiques sans nom d’hôte connu. Les adresses privées et réservées ne quittent jamais votre MacActivé par défaut. Interrupteur d’arrêt dans Capture › Identifier les propriétaires de réseau Demander avec un modèle hébergéLe fournisseur que vous choisissez : Anthropic, OpenAI, Google Gemini ou un point de terminaison compatibleVotre question et les résultats renvoyés par les requêtes Flowlight en lecture seule choisies par le modèle. La base de données de trafic elle-même n’est pas envoyéeSeulement quand vous choisissez un fournisseur hébergé et soumettez une question. L’écran Demander montre les requêtes transmises diff --git a/site/pages/fr/threat-model.html b/site/pages/fr/threat-model.html index b5095862..fca0d46c 100644 --- a/site/pages/fr/threat-model.html +++ b/site/pages/fr/threat-model.html @@ -87,36 +87,34 @@

Ce que l’inspection manque

des deux s’applique, app par app.

Ce que le blocage est, et n’est pas

-

Une règle qui refuse des connexions est exécutée par l’extension réseau, sur le chemin, avant que la connexion ne soit faite. Une règle qui - nomme une URL est exécutée par le proxy d’inspection, qui répond à la requête avec un statut que l’app peut lire. Ce sont les deux seules - parties de Flowlight qui agissent au lieu de rapporter — l’échantillonneur nettop, lui, ne peut rien refuser du tout : sur un Mac qui - capture avec l’échantillonneur, aucune règle n’est appliquée, et Flowlight le dit plutôt que de proposer un interrupteur qui ne fait rien.

+

Une règle qui refuse des connexions est normalement exécutée par l’extension réseau, sur le chemin, avant que la connexion ne soit faite. Pour + le trafic acheminé par l’inspection HTTPS, le proxy local applique les règles de destination et les packs gérés activés au client d’origine + avant d’ouvrir la connexion au serveur d’origine ; une règle qui nomme une URL y est également exécutée après le déchiffrement, avec un + statut que l’app peut lire. Ce sont les deux seules parties de Flowlight qui agissent au lieu de rapporter — l’échantillonneur nettop, lui, ne + peut rien refuser du tout : sur un Mac qui capture avec l’échantillonneur, aucune règle n’est appliquée, et Flowlight le dit plutôt que de + proposer un interrupteur qui ne fait rien.

Ce n’est pourtant toujours pas empêcher. Un agent qui peut lancer un shell peut faire à la main ce que l’outil refusé aurait fait. Un refus que vous pouvez annuler d’un clic est un refus que la tentative suivante d’un agent peut franchir. Le trafic local, le DNS, les services d’Apple et les connexions de Flowlight lui-même ne sont jamais refusés, parce qu’un Mac bloqué est un pire résultat qu’un agent bloqué. Et l’application des refus cesse quelques secondes après l’arrêt de Flowlight : un refus que personne ne peut voir est pire que pas de refus.

-

Inspection et refus ne se combinent pas. Le trafic passé par le proxy de Flowlight lui-même quitte le Mac comme du trafic de - Flowlight, et Flowlight ne se refuse jamais lui-même — une app dont vous lisez les requêtes est donc une app dont les règles n’arrêtent plus - les connexions. Activer l’inspection pour en voir plus vous coûte l’application des refus, et rien à l’écran ne le disait jusqu’ici.

+

L’inspection préserve le refus par nom d’hôte, avec une limite. Le trafic acheminé par le proxy de Flowlight quitte le Mac comme + du trafic Flowlight exempté ; le proxy décide donc d’abord des règles de destination ordinaires et des packs gérés activés en utilisant + l’identité d’origine de l’app. Il peut le faire même pour les tunnels épinglés ou jamais déchiffrés, car CONNECT nomme l’hôte. Les listes + d’autorisation des agents restent une décision de l’Extension réseau et ne s’appliquent pas à ce trajet vers le serveur d’origine par le proxy ; + le trafic sans propriétaire fiable ou sans nom d’hôte est autorisé plutôt que refusé sans que personne ne le voie.

Ce que Flowlight lui-même conserve

Des métadonnées de trafic dans une base de données SQLite locale, avec une durée de conservation publiée. Avec l’inspection HTTPS activée, il conserve aussi les corps des requêtes et des réponses pendant trois jours — la chose la plus sensible qu’il garde jamais. Les en-têtes d’identification sont masqués par nom et par mot, ce qui est une heuristique et non une garantie ; un en-tête qui porte un secret sous un nom que rien ne reconnaît peut être enregistré.

-

La base de données de trafic n’est jamais téléversée. Trois choses plus petites quittent bien le Mac sans qu’on les demande, toutes trois - nommées sur la page Confidentialité : la recherche de mise à jour, qui demande à GitHub la dernière version - au lancement et une fois par jour ; la consultation du propriétaire du réseau, qui interroge Team Cymru sur les adresses publiques sans - nom d’hôte ; et, tant que Flowlight lit des compteurs au lieu de faire tourner son extension, une recherche DNS inverse des adresses dont - il n’a vu aucun nom. Les deux premières ont un interrupteur d’arrêt. La troisième non, parce que sur l’échantillonneur c’est l’essentiel de la - façon dont une connexion obtient un nom : elle va au résolveur que le Mac utilise déjà, et elle s’arrête dès que l’extension prend le - relais et nomme les connexions à partir du trafic lui-même. Demander avec un modèle hébergé envoie bien plus que n’importe laquelle d’entre - elles, et c’est la seule chose qui reste désactivée jusqu’à ce que vous la configuriez.

+

La base de données de trafic n’est jamais téléversée. La page Confidentialité répertorie chaque requête sortante : vérifications de version et de packs de règles signés, consultation facultative du propriétaire du réseau et DNS inverse avec l’échantillonneur. Les trois premières ont des interrupteurs ; le DNS inverse non, car sans lui l’échantillonneur ne pourrait pas nommer la plupart des connexions. Un modèle hébergé envoie bien plus et reste désactivé jusqu’à configuration.

Mises à jour

Les versions sont signées avec un Developer ID et notarisées. Le programme de mise à jour exige une somme de contrôle publiée et refuse un téléchargement qui n’en a pas, puis vérifie que l’app téléchargée est signée par la même équipe que la copie en cours d’exécution avant de l’installer. Une mise à jour signée par quelqu’un d’autre est refusée, même si elle est valide par ailleurs.

+

Les packs de règles officiels utilisent une signature distincte limitée à cet usage et une source HTTPS fixe unique. L’application conserve séparément un repère de la séquence la plus élevée, revérifie au démarrage les octets exacts mis en cache, rejette les retours en arrière et les publications contradictoires, et ne considère jamais la signature d’un éditeur comme une autorisation d’appliquer un contenu modifié. Les packs nouveaux ou modifiés exigent l’examen explicite du contenu exact et Enregistrer ; le contenu obsolète déjà accepté est étiqueté comme dernière version valide connue, tandis qu’une révocation de confiance ou des octets corrompus entraînent un refus sécurisé.

Signaler une vulnérabilité

Utilisez le signalement privé de vulnérabilités de GitHub, ou écrivez à diff --git a/site/pages/index.html b/site/pages/index.html index 1263796d..5509d049 100644 --- a/site/pages/index.html +++ b/site/pages/index.html @@ -180,7 +180,9 @@

Monitor how AI agents use the network

⛔ Opt-in

Managed ad and tracker packs

Three official packs — Advertising, Cross-site tracking, and Privacy and telemetry — are built in, visible and off by - default. With the Network Extension, apply one to every app or only selected apps; preview it against retained traffic, + default. Flowlight checks one fixed HTTPS feed for signed updates; new or changed content remains out of enforcement until + you review that exact version and press Save. During an outage, verified accepted content is visibly stale last-known-good.

+

With the Network Extension, apply one to every app or only selected apps; preview it against retained traffic, see grouped, explainable activity, and add pack-specific exceptions for an app. Ordinary allow rules remain the universal escape hatch, global Pause Blocking applies, and essential system traffic stays allowed.

They block by hostname, so they cannot remove first-party ads, DOM or layout gaps, browser overlays, scripted popups, or @@ -387,7 +389,7 @@

A monitor you don't have to monitor

Stays on your Mac

One local SQLite file. No account, no cloud sync, no telemetry.

Metadata by default

Hostnames, ports and byte counts. Decrypting HTTPS is a separate mode you have to turn on, and it stays on your Mac.

Filtered in the kernel

Capture passes only DNS answers and TLS ClientHellos. Everything else is dropped before Flowlight sees it.

-
Lookups you control

Naming who owns an address sends only public IPs with no hostname to Team Cymru, and turns off in one click. The daily update check only asks GitHub for the latest version, and can be turned off too.

+
Lookups you control

Naming who owns an address sends only public IPs with no hostname to Team Cymru. Release and signed rule-pack checks send standard HTTPS requests with no traffic or pack settings. Each has an off switch.

diff --git a/site/pages/it/docs.html b/site/pages/it/docs.html index cb128f2d..b6bd7cbd 100644 --- a/site/pages/it/docs.html +++ b/site/pages/it/docs.html @@ -381,6 +381,11 @@

Regole

sicura. La schermata indica anche il traffico più vecchio che ha potuto vedere: una regola testata su due ore di storico è stata testata su due ore di storico.

+

Pacchetti gestiti per pubblicità e tracker

+

Regole › Pacchetti contiene tre elenchi ufficiali di nomi host: Pubblicità, Tracciamento tra siti e Privacy e telemetria. Sono visibili fin dall’inizio e tutti disattivati per impostazione predefinita. Il blocco richiede l’Estensione di rete; il campionatore nettop può solo osservare.

+

Solo aggiornamenti ufficiali e firmati. Flowlight controlla un’unica fonte HTTPS fissa per trovare cataloghi più recenti firmati dall’editore. I controlli automatici sono attivi per impostazione predefinita e possono essere disattivati qui. Una firma autentica il contenuto, ma non lo attiva: un nuovo pacchetto resta disattivato e, se cambiano le destinazioni, l’applicazione di un pacchetto attivo viene sospesa finché non esamini l’esatta nuova versione e premi Salva. Durante un’interruzione, i contenuti accettati possono restare disponibili come ultima versione valida nota, visibilmente indicata come obsoleta. Si tratta di blocchi di rete a livello di nome host: non possono eliminare spazi vuoti nel DOM o nel layout, sovrapposizioni del browser, popup generati da script o finestre di app native.

+

Quando l’ispezione HTTPS instrada un’app attraverso il proxy di Flowlight, questo decide le normali regole di destinazione e i pacchetti gestiti attivi usando l’identità originale dell’app prima di aprire qualsiasi connessione al server di origine. Per i tunnel CONNECT con pinning o mai decifrati, ciò funziona in base al nome host; gli elenchi consentiti degli agenti restano limitati al percorso diretto attraverso l’Estensione di rete. Se il proprietario o il nome host è sconosciuto, la connessione viene consentita.

+

Elenchi consentiti

Seleziona un agente in Agenti IA e usa Elenco consentiti nel suo dettaglio per dire dove può connettersi. Inserisci domini (sottodomini compresi), indirizzi IP o intervalli CIDR, oppure scegli una preimpostazione: GitHub, npm, PyPI, Homebrew, Docker Hub, Rust crates, Go modules o @@ -399,8 +404,7 @@

Rifiutare le connessioni

stesso. Un Mac bloccato è un esito peggiore di un agente bloccato, quindi nel dubbio la connessione viene consentita e registrata. L'applicazione decade anche pochi secondi dopo che Flowlight smette di funzionare: un rifiuto che nessuno può vedere è peggio di nessun rifiuto, e il modo per annullarne uno sta nell'app.

-

Ispezione HTTPS e blocco non si combinano: il traffico instradato attraverso il proxy di Flowlight esce come traffico di Flowlight, che non viene - mai rifiutato.

+

Per un’app instradata attraverso l’ispezione HTTPS, il proxy di Flowlight decide le normali regole di destinazione e i pacchetti gestiti attivi usando l’identità originale dell’app prima di aprire qualsiasi connessione al server di origine. Per i tunnel CONNECT con pinning o mai decifrati, lo fa in base al nome host. Gli elenchi consentiti degli agenti restano limitati al percorso diretto attraverso l’Estensione di rete; se il proprietario o il nome host è sconosciuto, la connessione viene consentita.

Protezioni per gli agenti: bloccare uno strumento, un server o una risorsa

Quali strumenti può usare questo agente è una domanda diversa da quali host può raggiungere, quindi ha una @@ -620,9 +624,7 @@

Domande e risposte

Mostra quali estensioni sono installate, se macOS ha davvero avviato il filtro di Flowlight e quanto di recente è stato registrato qualcosa. Incollalo in una segnalazione.
Flowlight invia da qualche parte informazioni su di me?
-
Nessun account, nessuna telemetria, nessuna analisi, e il tuo database del traffico non viene mai caricato. Tre richieste lasciano il Mac, tutte elencate nella - pagina sulla privacy: il controllo giornaliero degli aggiornamenti e la ricerca facoltativa sul proprietario della rete, entrambe con - un interruttore per disattivarle, e il DNS inverso finché Flowlight è sul campionatore, che non ne ha uno — senza di esso il campionatore non potrebbe dare un nome alla maggior parte delle connessioni.
+
Nessun account, nessuna telemetria, nessuna analisi, e il tuo database del traffico non viene mai caricato. La pagina sulla privacy elenca ogni richiesta: i controlli della versione e dei pacchetti di regole firmati, insieme alla ricerca facoltativa del proprietario della rete, hanno interruttori; il DNS inverso funziona solo con il campionatore e non ne ha uno, perché senza di esso non potrebbe nominare la maggior parte delle connessioni.
Rallenta il Mac o consuma la batteria?
Con la finestra chiusa Flowlight fa quasi nulla: mantiene le velocità nella barra dei menu e scrive una riga al secondo. L'estensione di rete costa meno del campionatore di riserva, che avvia un breve processo nettop ogni secondo.
diff --git a/site/pages/it/index.html b/site/pages/it/index.html index 8c5c8701..c8e489ee 100644 --- a/site/pages/it/index.html +++ b/site/pages/it/index.html @@ -167,6 +167,7 @@

Controlla come gli agenti IA usano la rete

software di cui non ti fidi — vedi il modello di minaccia.

Cursor ⇥ raw.githubusercontent.com · rifiutata 4 volte · nei giorni feriali 18:00–09:00

+
⛔ Da attivare

Pacchetti gestiti per pubblicità e tracker

Tre pacchetti ufficiali sono integrati, visibili e disattivati per impostazione predefinita. Flowlight controlla un unico feed HTTPS fisso per gli aggiornamenti firmati; i contenuti nuovi o modificati restano esclusi dall’applicazione finché non esamini quella versione esatta e premi Salva. Durante un’interruzione, i contenuti verificati e accettati possono restare disponibili come ultima versione valida nota, visibilmente indicata come obsoleta.

Bloccano in base al nome host, quindi non possono rimuovere pubblicità proprietarie, spazi vuoti nel DOM o nel layout, sovrapposizioni del browser, popup generati da script o finestre native delle app.

⛔ Rifiuta

Protezioni sugli strumenti di un agente

@@ -366,7 +367,7 @@

Un monitoraggio che non devi monitorare

Resta sul tuo Mac

Un solo file SQLite locale. Nessun account, nessuna sincronizzazione nel cloud, nessuna telemetria.

Metadati, per impostazione predefinita

Nomi host, porte e byte conteggiati. Decifrare HTTPS è una modalità separata che devi attivare tu, e resta sul tuo Mac.

Filtrato nel kernel

L'acquisizione lascia passare solo le risposte DNS e i ClientHello TLS. Tutto il resto viene scartato prima che Flowlight lo veda.

-
Ricerche che controlli tu

Per dare un nome al proprietario di un indirizzo vengono inviati a Team Cymru solo gli IP pubblici senza nome host, e si disattiva con un clic. Il controllo giornaliero degli aggiornamenti chiede a GitHub solo l'ultima versione, e anche quello si può disattivare.

+
Ricerche che controlli tu

Per dare un nome al proprietario di un indirizzo vengono inviati a Team Cymru solo gli IP pubblici senza nome host. I controlli della versione e dei pacchetti di regole firmati inviano richieste HTTPS standard senza traffico né impostazioni dei pacchetti. Ciascuno ha il proprio interruttore.

diff --git a/site/pages/it/privacy.html b/site/pages/it/privacy.html index 70237343..11985fb3 100644 --- a/site/pages/it/privacy.html +++ b/site/pages/it/privacy.html @@ -56,6 +56,7 @@

Richieste di rete che fa l'app

RichiestaInviata aCosa contieneQuando Controllo aggiornamentiapi.github.comUna normale richiesta HTTPS per l'ultima versione; GitHub vede il tuo indirizzo IP e lo user agent Flowlight/<version>All'avvio e una volta al giorno, dalla 0.1.2. Interruttore per disattivarla in Impostazioni › Generali Download dell'aggiornamentogithub.comL'immagine disco e il suo file di checksumSolo quando fai clic su Scarica e apri + Aggiornamento ufficiale del pacchetto di regoleflowlight.xinbetween.comUna richiesta HTTPS standard al feed ufficiale fisso. Il server vede il tuo indirizzo IP, lo user agent Flowlight/<version> e i validatori condizionali della cache; non riceve traffico, impostazioni dei pacchetti o identificatori del dispositivoDopo l’avvio, quando previsto, e successivamente non più di una volta al giorno durante il normale funzionamento. Attivo per impostazione predefinita; interruttore per disattivarlo e opzione manuale Controlla ora in Regole › Pacchetti DNS inversoIl resolver DNS che hai configuratoUna ricerca del nome registrato di un indirizzo IP, quando non è stato rilevato alcun nome hostQuando serve, nella modalità di acquisizione a campionamento, che è quella predefinita finché l'estensione di rete non è in funzione. Nessun interruttore per disattivarla: senza di essa il campionatore non può dare un nome alla maggior parte delle connessioni. L'estensione di rete le nomina dal traffico stesso e non fa questa ricerca Ricerca del proprietario della reteTeam Cymru, via DNSIndirizzi IP pubblici senza nome host noto. Gli indirizzi privati e riservati non lasciano mai il tuo MacAttiva per impostazione predefinita. Interruttore per disattivarla in Acquisizione › Identifica i proprietari della rete Chiedi con un modello ospitatoIl provider che selezioni: Anthropic, OpenAI, Google Gemini o un endpoint compatibileLa tua domanda e i risultati restituiti dalle query di sola lettura di Flowlight selezionate dal modello. Il database del traffico non viene caricatoSolo quando selezioni un provider ospitato e invii una domanda. La schermata Chiedi mostra le richieste trasmesse diff --git a/site/pages/it/threat-model.html b/site/pages/it/threat-model.html index 5dffcd03..82b0377a 100644 --- a/site/pages/it/threat-model.html +++ b/site/pages/it/threat-model.html @@ -86,35 +86,34 @@

Cosa sfugge all'ispezione

cose valga, app per app.

Cosa è, e cosa non è, il blocco

-

Una regola che rifiuta le connessioni viene eseguita dall'estensione di rete, sul percorso, prima che la connessione venga stabilita. Una - regola che nomina un URL viene eseguita invece dal proxy di ispezione, che risponde alla richiesta con uno stato che l'app può leggere. Sono le - sole due parti di Flowlight che agiscono invece di riferire — il campionatore nettop non può rifiutare proprio nulla, quindi su un Mac che - acquisisce con il campionatore nessuna regola viene applicata, e Flowlight lo dice invece di offrire un interruttore che non fa niente.

+

Una regola che rifiuta le connessioni viene normalmente eseguita dall'estensione di rete, sul percorso, prima che la connessione venga stabilita. + Per il traffico instradato attraverso l'ispezione HTTPS, il proxy locale applica le regole di destinazione e i pacchetti gestiti attivi al client + originale prima di aprire la connessione al server di origine; una regola che nomina un URL viene eseguita anche lì, dopo la decifratura, con uno + stato che l'app può leggere. Sono le sole due parti di Flowlight che agiscono invece di riferire — il campionatore nettop non può rifiutare + proprio nulla, quindi su un Mac che acquisisce con il campionatore nessuna regola viene applicata, e Flowlight lo dice invece di offrire un + interruttore che non fa niente.

Non è comunque impedire. Un agente che può eseguire una shell può fare a mano ciò che avrebbe fatto lo strumento rifiutato. Un rifiuto che puoi annullare con un clic è un rifiuto attraverso cui il tentativo successivo di un agente può passare. Il traffico locale, il DNS, i servizi di Apple e le connessioni di Flowlight stesso non vengono mai rifiutati, perché un Mac bloccato è un esito peggiore di un agente bloccato. E l'applicazione decade pochi secondi dopo che Flowlight smette di funzionare: un rifiuto che nessuno può vedere è peggio di nessun rifiuto.

-

Ispezione e rifiuto non si combinano. Il traffico instradato attraverso il proxy di Flowlight stesso lascia il Mac come - traffico di Flowlight, e Flowlight non rifiuta mai se stesso — quindi un'app di cui stai leggendo le richieste è un'app le cui connessioni le - regole non fermano più. Attivare l'ispezione per vedere di più ti costa l'applicazione delle regole, e finora nulla sullo schermo lo diceva.

+

L'ispezione mantiene il rifiuto per nome host, con un limite. Il traffico instradato attraverso il proxy di Flowlight lascia il + Mac come traffico Flowlight esente, quindi il proxy decide prima le normali regole di destinazione e i pacchetti gestiti attivi usando l'identità + originale dell'app. Può farlo anche per tunnel con pinning o mai decifrati, perché CONNECT specifica l'host. Gli elenchi consentiti degli agenti + restano una decisione dell'Estensione di rete e non si applicano a quel tratto verso il server di origine attraverso il proxy; il traffico senza + un proprietario attendibile o senza nome host viene consentito invece di essere rifiutato senza che nessuno lo veda.

Cosa conserva Flowlight stesso

Metadati del traffico in un database SQLite locale, con una conservazione pubblicata. Con l'ispezione HTTPS attiva conserva anche i corpi delle richieste e delle risposte per tre giorni — la cosa più delicata che arrivi mai a tenere. Le intestazioni con credenziali vengono oscurate per nome e per parola, che è un'euristica e non una garanzia; un'intestazione che porta un segreto sotto un nome che nulla riconosce può essere registrata.

-

Il database del traffico non viene mai caricato. Tre cose più piccole lasciano il Mac senza che nessuno le chieda, tutte e tre nominate nella - pagina sulla privacy: il controllo degli aggiornamenti, che chiede a GitHub l'ultima versione all'avvio e una - volta al giorno; la ricerca del proprietario della rete, che chiede a Team Cymru degli indirizzi pubblici senza nome host; e, finché Flowlight - legge i contatori invece di far girare la sua estensione, una ricerca DNS inversa degli indirizzi per cui non ha visto alcun nome. Le prime due - hanno un interruttore per disattivarle. La terza no, perché sul campionatore è in gran parte il modo in cui una connessione arriva ad avere un - nome: va al resolver che il Mac già usa, e si ferma appena l'estensione prende il suo posto e nomina le connessioni dal traffico stesso. Chiedi - con un modello ospitato invia molto più di ognuna di esse, ed è la sola cosa che resta disattivata finché non la configuri.

+

Il database del traffico non viene mai caricato. La pagina sulla privacy elenca ogni richiesta in uscita: controlli della versione e dei pacchetti di regole firmati, ricerca facoltativa del proprietario della rete e DNS inverso con il campionatore. Le prime tre hanno interruttori; il DNS inverso no, perché senza di esso il campionatore non potrebbe nominare la maggior parte delle connessioni. Un modello ospitato invia molto di più e resta disattivato finché non lo configuri.

Aggiornamenti

Le versioni sono firmate con un Developer ID e notarizzate. L'aggiornamento richiede una checksum pubblicata e rifiuta un download che non ne ha una, poi verifica che l'app scaricata sia firmata dallo stesso team della copia in esecuzione prima di installarla. Un aggiornamento firmato da qualcun altro viene rifiutato anche se per il resto è valido.

+

I pacchetti di regole ufficiali usano una firma distinta e limitata a questo scopo e un’unica fonte HTTPS fissa. L’app conserva separatamente un marcatore della sequenza più alta, verifica di nuovo all’avvio gli esatti byte memorizzati nella cache, rifiuta rollback e pubblicazioni discordanti e non considera mai la firma dell’editore un’autorizzazione ad applicare contenuti modificati. I pacchetti nuovi o modificati richiedono l’esame esplicito del contenuto esatto e Salva; i contenuti obsoleti già accettati sono indicati come ultima versione valida nota, mentre la revoca dell’attendibilità o byte danneggiati fanno rifiutare il contenuto per sicurezza.

Segnalare una vulnerabilità

Usa la segnalazione privata delle vulnerabilità di GitHub, oppure scrivi a diff --git a/site/pages/ja/docs.html b/site/pages/ja/docs.html index aa7c3d80..e66c611e 100644 --- a/site/pages/ja/docs.html +++ b/site/pages/ja/docs.html @@ -352,6 +352,11 @@

ルール

区別します。同じ空の一覧でも意味は正反対で、ルールが安全だと言えるのはそのうち一方だけです。画面は、見えたいちばん古いトラフィックも示します。2 時間分の 履歴でテストしたルールは、2 時間分の履歴でテストしたということです。

+

管理対象の広告・トラッカーパック

+

ルール › パックには、広告、サイト横断トラッキング、プライバシーとテレメトリという3つの公式ホスト名リストがあります。これらは最初から表示され、すべてデフォルトでオフになっています。ブロックにはネットワーク機能拡張が必要です。nettop サンプラーは監視のみ可能です。

+

公式の署名付きアップデートのみ。 Flowlight は、公開元が署名した新しいカタログがないか、1つの固定 HTTPS ソースを確認します。自動確認はデフォルトでオンになっており、ここでオフにできます。署名はコンテンツを認証しますが、有効化はしません。新しいパックはオフのままで、宛先が変更された場合は、正確な新バージョンを確認して保存を押すまで、有効なパックの適用が停止されます。障害中も、承認済みコンテンツが、古いことを明示した既知の正常な最終版として保持される場合があります。これらはホスト名単位のネットワークブロックです。DOM やレイアウトの空白、ブラウザのオーバーレイ、スクリプトによるポップアップ、ネイティブアプリのウインドウは除去できません。

+

HTTPS インスペクションがアプリを Flowlight のプロキシ経由にすると、接続先の通常ルールと有効な管理対象パックは、接続先サーバへの接続が開かれる前に、元のアプリの識別情報を使って判定されます。証明書がピン留めされている、または復号されない CONNECT トンネルでも、ホスト名によって判定できます。エージェントの許可リストは引き続き、ネットワーク機能拡張を直接通る経路だけに適用されます。所有者またはホスト名が不明な場合は、接続を許可します。

+

許可リスト

AI エージェントでエージェントを選び、その詳細の許可リストで、どこへ接続してよいかを指定します。ドメイン(サブドメインを含みます)、 IP アドレス、CIDR 範囲を入力するか、プリセットを選びます。GitHub、npm、PyPI、Homebrew、Docker Hub、Rust crates、Go modules、Apple があります。 @@ -368,7 +373,7 @@

接続を拒否する

リストが何と言っていても決して拒否されないものがあります。ローカルネットワーク、DNS、Apple 純正のサービス、そして Flowlight 自身のトラフィックです。 ブロックされたエージェントより、ブロックされた Mac のほうが悪い結果なので、迷いがある場合は接続を許可して記録します。強制は Flowlight が動作を止めた数秒後に 失効もします。誰にも見えない拒否は、拒否がないより悪く、それを取り消す手段はアプリの中にあるからです。

-

HTTPS インスペクションとブロックは組み合わせられません。Flowlight のプロキシを通るトラフィックは Flowlight 自身のものとして出ていき、それは決して拒否されないからです。

+

HTTPS インスペクションを経由するアプリについては、Flowlight のプロキシが接続先サーバへの接続を開く前に、元のアプリの識別情報を使って、接続先の通常ルールと有効な管理対象パックを判定します。証明書がピン留めされている、または復号されない CONNECT トンネルでは、ホスト名によって判定します。エージェントの許可リストは引き続き、ネットワーク機能拡張を直接通る経路だけに適用されます。所有者またはホスト名が不明な場合は、接続を許可します。

エージェントのガードレール:ツール、サーバ、リソースをブロックする

このエージェントはどのツールを使ってよいかは、どのホストへ届いてよいかとは別の問いなので、独立した場所があります。AI エージェントの @@ -569,10 +574,7 @@

質問と回答

どの機能拡張がインストールされているか、macOS が本当に Flowlight のフィルタを起動したか、最後に何かが記録されたのはいつかがわかります。 issue に貼り付けてください。
Flowlight は私に関する情報をどこかに送りますか?
-
アカウントもテレメトリも分析もなく、トラフィックのデータベースがアップロードされることもありません。Mac から出ていくリクエストは 3 つあり、 - どれもプライバシーのページに挙げてあります。1 日 1 回のアップデート確認と、任意のネットワーク所有者の照会。 - この 2 つにはオフのスイッチがあります。そして Flowlight がサンプラーで動いているあいだの逆引き DNS。こちらにはスイッチがありません。 - これがないと、サンプラーはほとんどの接続に名前を付けられないからです。
+
アカウントもテレメトリも分析もなく、トラフィックのデータベースがアップロードされることもありません。プライバシーのページにはすべてのリクエストを記載しています。リリースと署名付きルールパックの確認、任意のネットワーク所有者照会にはオフのスイッチがあります。逆引き DNS はサンプラー使用時だけ動作し、これがないと多くの接続に名前を付けられないためスイッチはありません。
Mac が遅くなったり、バッテリーが減ったりしませんか?
ウインドウを閉じているあいだ、Flowlight はほとんど何もしません。メニューバーの速度を保ち、1 秒に 1 行書き込むだけです。ネットワーク機能拡張のほうが、 1 秒ごとに短命な nettop プロセスを起動する代替のサンプラーよりも負荷が低いです。
diff --git a/site/pages/ja/index.html b/site/pages/ja/index.html index 291252b4..90ce79ac 100644 --- a/site/pages/ja/index.html +++ b/site/pages/ja/index.html @@ -167,6 +167,7 @@

AI エージェントのネットワーク利用を見張る

信頼していないソフトウェアを阻む壁ではありません。脅威モデルをご覧ください。

Cursor ⇥ raw.githubusercontent.com · 4 回拒否 · 平日 18:00–09:00

+
⛔ オプトイン

管理対象の広告・トラッカーパック

3 つの公式パックが内蔵され、最初から表示されていますが、デフォルトではすべてオフです。Flowlight は署名付きアップデートがないか、1 つの固定 HTTPS フィードを確認します。新しいコンテンツや変更されたコンテンツは、その正確なバージョンを確認して保存を押すまで適用されません。障害中は、検証・受け入れ済みのコンテンツが、古くなっていることを明示した最後に正常と確認された版として保持される場合があります。

ホスト名単位でブロックするため、ファーストパーティ広告、DOM やレイアウトの空白、ブラウザのオーバーレイ、スクリプトによるポップアップ、ネイティブアプリのウインドウは除去できません。

⛔ 拒否

エージェントのツールにガードレール

@@ -365,7 +366,7 @@

監視しなくていい監視ツール

Mac の中だけ

ローカルの SQLite ファイル 1 つ。アカウントなし、クラウド同期なし、テレメトリなし。

標準はメタデータだけ

ホスト名、ポート、バイト数。HTTPS の復号は自分でオンにする別のモードで、これも Mac の中にとどまります。

カーネルで絞り込み

キャプチャが通すのは DNS の応答と TLS の ClientHello だけ。ほかは Flowlight が見る前に捨てられます。

-
問い合わせは自分で決める

アドレスの所有者を調べる機能が Team Cymru に送るのは、ホスト名のないパブリック IP だけで、ワンクリックでオフにできます。1 日 1 回のアップデート確認も、GitHub に最新バージョンを尋ねるだけで、こちらもオフにできます。

+
問い合わせは自分で決める

アドレスの所有者を調べる機能が Team Cymru に送るのは、ホスト名のないパブリック IP だけです。リリースと署名付きルールパックの確認は、トラフィックやパック設定を含まない標準 HTTPS リクエストを送ります。それぞれ個別にオフにできます。

diff --git a/site/pages/ja/privacy.html b/site/pages/ja/privacy.html index c672b344..97dfbe13 100644 --- a/site/pages/ja/privacy.html +++ b/site/pages/ja/privacy.html @@ -54,6 +54,7 @@

アプリが行うネットワークリクエスト

リクエスト送信先含まれるものタイミング アップデートの確認api.github.com最新リリースを尋ねる通常の HTTPS リクエスト。GitHub が見るのは、IP アドレスと Flowlight/<version> というユーザーエージェントです起動時と 1 日 1 回(0.1.2 以降)。オフのスイッチは設定 › 一般 アップデートのダウンロードgithub.comディスクイメージと、そのチェックサムファイル「ダウンロードして開く」をクリックしたときだけ + 公式ルールパックのアップデートflowlight.xinbetween.com固定された公式フィードへの標準 HTTPS リクエストです。サーバには、IP アドレス、Flowlight/<version>ユーザーエージェント、条件付きキャッシュバリデータが表示されます。トラフィック、パック設定、デバイス識別子は送信されません起動後、確認期限に達している場合に実行し、その後は正常稼働中に1日最大1回実行します。デフォルトでオンです。ルール › パックでオフにするか、今すぐ確認を手動で実行できます 逆引き DNSあなたが設定した DNS リゾルバホスト名が見つからなかった場合の、IP アドレスの登録名の照会必要に応じて、サンプラーのキャプチャモードで。ネットワーク機能拡張が動き出すまではこれがデフォルトです。オフのスイッチはありません。これがないとサンプラーはほとんどの接続に名前を付けられないからです。ネットワーク機能拡張はトラフィック自身から名前を付けるので、これを行いません ネットワーク所有者の照会Team Cymru(DNS 経由)ホスト名がわからないパブリック IP アドレス。プライベートアドレスと予約アドレスは、この Mac から決して出ませんデフォルトでオン。オフのスイッチはキャプチャ › ネットワーク所有者を特定 ホスト型モデルでの質問あなたが選んだプロバイダ:Anthropic、OpenAI、Google Gemini、または互換のエンドポイントあなたの質問と、モデルが選んだ読み取り専用の Flowlight の問い合わせが返した結果。トラフィックのデータベース自体はアップロードされませんホスト型のプロバイダを選び、質問を送信したときだけ。質問の画面には、送信されたリクエストが表示されます diff --git a/site/pages/ja/threat-model.html b/site/pages/ja/threat-model.html index e7a163c8..ce65d512 100644 --- a/site/pages/ja/threat-model.html +++ b/site/pages/ja/threat-model.html @@ -86,36 +86,33 @@

インスペクションが取りこぼすもの

ありません。どちらなのかは、カバー範囲がアプリごとに答えます。

ブロックとは何で、何でないか

-

接続を拒否するルールを実行するのはネットワーク機能拡張で、その経路の中で、接続が行われる前に実行します。URL を指定する - ルールを実行するのはインスペクション用のプロキシで、アプリが読める状態コードでリクエストに応えます。報告するのではなく実際に - 手を出す Flowlight の部分は、この 2 つだけです。nettop サンプラーは何ひとつ拒否できないので、サンプラーで動いている Mac では - どのルールも強制されません。Flowlight は何もしないスイッチを見せるのではなく、そのことをはっきり言います。

+

接続を拒否するルールは通常、ネットワーク機能拡張がその経路の中で、接続が行われる前に実行します。HTTPS インスペクションを + 経由するトラフィックについては、ローカルプロキシが接続先の通常ルールと有効な管理対象パックを元のクライアントに対して適用してから、 + 接続先サーバへの接続を開きます。URL を指定するルールも復号後にそこで実行され、アプリが読める状態コードで応答します。報告する + のではなく実際に手を出す Flowlight の部分は、この 2 つだけです。nettop サンプラーは何ひとつ拒否できないので、サンプラーで + 動いている Mac ではどのルールも強制されません。Flowlight は何もしないスイッチを見せるのではなく、そのことをはっきり言います。

それでも防止ではありません。シェルを使えるエージェントは、拒否されたツールがしたはずのことを手作業で行えます。ワンクリックで 取り消せる拒否は、エージェントの次の試みが通り抜けうる拒否でもあります。ローカルのトラフィック、DNS、Apple 純正のサービス、 Flowlight 自身の接続は決して拒否されません。ブロックされたエージェントより、ブロックされた Mac のほうが悪い結果だからです。 そして強制は、Flowlight が動作を止めた数秒後に失効します。誰にも見えない拒否は、拒否がないより悪いからです。

-

インスペクションと拒否は両立しません。Flowlight 自身のプロキシを通ったトラフィックは、Flowlight の - トラフィックとして Mac を出ていき、Flowlight が自分自身を拒否することはありません。つまり、リクエストを読んでいるアプリは、 - ルールではもう接続を止められないアプリです。もっと見るためにインスペクションをオンにすることは、強制を手放すことでもあり、 - 画面にはこれまでそう書かれていませんでした。

+

インスペクションでもホスト名による拒否は保たれますが、境界があります。Flowlight のプロキシを通ったトラフィックは、 + 除外対象の Flowlight トラフィックとして Mac を出ていくため、プロキシがまず元のアプリの識別情報を使って、接続先の通常ルールと + 有効な管理対象パックを判定します。CONNECT がホスト名を示すので、証明書がピン留めされている、または復号されないトンネルでも + 判定できます。エージェントの許可リストはネットワーク機能拡張による判定のままで、プロキシから接続先サーバへ向かう区間には + 適用されません。信頼できる所有者またはホスト名がないトラフィックは、見えないところで拒否せず、接続を許可します。

Flowlight 自身が保持するもの

トラフィックのメタデータを、ローカルの SQLite データベースに、公開された保持期間のあいだ保持します。HTTPS インスペクションを オンにしていれば、リクエストとレスポンスの本文も 3 日間保持します。これは Flowlight が保持するもののなかで最も機微なものです。 認証情報のヘッダは名前と語によって隠されますが、それはヒューリスティックであって保証ではありません。何も認識できない名前のもとに 秘密を載せたヘッダは、記録されることがあります。

-

トラフィックのデータベースがアップロードされることはありません。それでも、頼まなくても Mac から出ていくものが 3 つあり、 - どれもプライバシーのページに挙げてあります。起動時と 1 日 1 回、最新のリリースを GitHub に - 尋ねるアップデートの確認。ホスト名のない公開アドレスについて Team Cymru に尋ねるネットワーク所有者の照会。そして、 - Flowlight が機能拡張ではなくカウンタを読んでいるあいだ、名前が見つからなかったアドレスの逆引き DNS 照会です。 - はじめの 2 つにはオフのスイッチがあります。3 つめにはありません。サンプラーでは、接続に名前が付く道のほとんどがそれだからです。 - 照会はこの Mac がすでに使っているリゾルバへ行き、機能拡張が引き継いでトラフィック自身から名前を付けるようになれば止まります。 - ホスト型モデルでの質問は、そのどれよりもはるかに多くを送りますが、設定するまではオフのままである唯一のものです。

+

トラフィックのデータベースがアップロードされることはありません。プライバシーのページには、リリースと署名付きルールパックの確認、任意のネットワーク所有者照会、サンプラーの逆引き DNS というすべての送信を記載しています。最初の 3 つにはオフのスイッチがあります。逆引き DNS は、多くの接続に名前を付けるため必要なのでありません。ホスト型モデルはより多くを送信し、設定するまでオフです。

アップデート

リリースは Developer ID で署名され、公証されています。アップデート機能は公開されたチェックサムを要求し、それがないダウンロードを 拒否します。そのうえで、ダウンロードしたアプリが実行中のコピーと同じチームの署名であることを確かめてから、インストールします。 別の誰かが署名したアップデートは、ほかの点で有効であっても拒否されます。

+

公式ルールパックは、用途を限定した独立の署名と、1 つの固定 HTTPS ソースを使用します。アプリは独立した最高シーケンス番号のウォーターマークを保持し、起動時にキャッシュされた正確なバイト列を再検証し、ロールバックと相互に矛盾する内容の提示を拒否します。また、公開元の署名を、変更されたコンテンツの適用を許可するものとして扱うことはありません。新しいパックや変更されたパックには、正確な内容の明示的な確認と保存が必要です。古くなった受け入れ済みコンテンツには、最後に正常と確認された版であることが表示されます。信頼が取り消された場合やバイト列が破損している場合は、安全側に倒して拒否します。

脆弱性を報告する

GitHub のプライベート脆弱性報告をお使いください。GitHub を diff --git a/site/pages/ko/docs.html b/site/pages/ko/docs.html index 83d15c34..3348aa8c 100644 --- a/site/pages/ko/docs.html +++ b/site/pages/ko/docs.html @@ -377,6 +377,11 @@

규칙

트래픽이 없습니다”를 구분합니다. 같은 빈 목록에 뜻은 정반대이고, 규칙이 안전하다고 말해 주는 것은 둘 중 하나뿐입니다. 화면은 볼 수 있었던 가장 오래된 트래픽도 함께 밝힙니다. 두 시간짜리 이력으로 대조한 규칙은 두 시간짜리 이력으로 대조한 것입니다.

+

관리형 광고 및 추적기 팩

+

규칙 › 팩에는 광고, 사이트 간 추적, 개인정보 보호 및 원격 측정이라는 세 가지 공식 호스트 이름 목록이 있습니다. 처음부터 표시되며 모두 기본적으로 꺼져 있습니다. 차단하려면 네트워크 확장 프로그램이 필요하며, nettop 샘플러는 관찰만 할 수 있습니다.

+

공식 서명 업데이트만 허용됩니다. Flowlight는 게시자가 서명한 최신 카탈로그가 있는지 하나의 고정 HTTPS 소스를 확인합니다. 자동 확인은 기본적으로 켜져 있으며 여기에서 끌 수 있습니다. 서명은 콘텐츠를 인증하지만 활성화하지는 않습니다. 새 팩은 꺼진 상태로 유지되며, 대상이 변경되면 활성화된 팩은 정확한 새 버전을 검토하고 저장을 누를 때까지 적용되지 않습니다. 서비스 중단 중에도 승인된 콘텐츠가 눈에 띄게 오래된 것으로 표시된 마지막 정상 버전으로 유지될 수 있습니다. 이는 호스트 이름 수준의 네트워크 차단이므로 DOM이나 레이아웃의 빈 공간, 브라우저 오버레이, 스크립트로 생성된 팝업 또는 네이티브 앱 창은 제거할 수 없습니다.

+

HTTPS 검사가 앱을 Flowlight의 프록시로 보내면, 일반 대상 규칙과 활성화된 관리형 팩은 원본 서버 연결이 열리기 전에 원래 앱의 신원을 사용해 결정됩니다. 인증서가 고정되었거나 복호화되지 않는 CONNECT 터널도 호스트 이름으로 판정할 수 있습니다. 에이전트 허용 목록은 계속 네트워크 확장을 직접 지나는 경로에만 적용됩니다. 소유자나 호스트 이름을 알 수 없으면 연결을 허용합니다.

+

허용 목록

AI 에이전트에서 에이전트를 고르고 상세의 허용 목록에서 어디에 연결할 수 있는지 정하세요. 도메인(서브도메인 포함)이나 IP 주소, CIDR 범위를 입력하거나 사전 설정을 고르면 됩니다. GitHub, npm, PyPI, Homebrew, Docker Hub, @@ -395,8 +400,7 @@

연결 거부

트래픽입니다. 막힌 Mac은 막힌 에이전트보다 나쁜 결과이므로, 애매할 때는 연결을 허용하고 기록합니다. 또한 Flowlight가 멈추면 몇 초 뒤에 적용이 풀립니다. 아무도 볼 수 없는 거부는 거부가 없는 것보다 나쁘고, 그것을 되돌리는 방법은 앱 안에 있기 때문입니다.

-

HTTPS 검사와 차단은 함께 쓸 수 없습니다. Flowlight의 프록시를 거친 트래픽은 Flowlight 자신의 트래픽으로 나가는데, 그것은 - 절대 거부되지 않기 때문입니다.

+

HTTPS 검사를 거치는 앱은 Flowlight의 프록시가 원본 서버 연결을 열기 전에 원래 앱의 신원을 사용해 일반 대상 규칙과 활성화된 관리형 팩을 결정합니다. 인증서가 고정되었거나 복호화되지 않는 CONNECT 터널에서는 호스트 이름으로 판정합니다. 에이전트 허용 목록은 계속 네트워크 확장을 직접 지나는 경로에만 적용됩니다. 소유자나 호스트 이름을 알 수 없으면 연결을 허용합니다.

에이전트 가드레일: 도구나 서버, 리소스를 막기

이 에이전트가 어떤 도구를 쓸 수 있나는 어떤 호스트에 닿을 수 있나와 다른 질문이므로 자리도 따로 @@ -611,10 +615,7 @@

질문과 답

어떤 확장이 설치되어 있는지, macOS가 Flowlight의 필터를 실제로 시작했는지, 그리고 가장 최근 기록이 언제인지 보여줍니다. 결과를 이슈에 붙여 주세요.
Flowlight가 내 정보를 어딘가로 보내나요?
-
계정도, 텔레메트리도, 분석도 없고, 트래픽 데이터베이스가 업로드되는 일도 없습니다. Mac을 떠나는 요청은 세 가지이고, 셋 다 - 개인정보 페이지에 나열되어 있습니다. 하루 한 번의 업데이트 확인과 선택 사항인 네트워크 소유자 조회, - 이 둘은 끄는 스위치가 있습니다. 그리고 Flowlight가 샘플러로 돌고 있는 동안의 역방향 DNS, 이것은 스위치가 없습니다. 그것이 없으면 - 샘플러는 대부분의 연결에 이름을 붙일 수 없습니다.
+
계정도, 텔레메트리도, 분석도 없고 트래픽 데이터베이스도 업로드되지 않습니다. 개인정보 페이지에 모든 요청이 나열됩니다. 릴리스와 서명된 규칙 팩 확인, 선택 사항인 네트워크 소유자 조회에는 끄기 스위치가 있습니다. 역방향 DNS는 샘플러에서만 실행되며, 없으면 대부분의 연결에 이름을 붙일 수 없어 스위치가 없습니다.
Mac이 느려지거나 배터리가 빨리 닳지 않나요?
창을 닫아 두면 Flowlight는 거의 아무 일도 하지 않습니다. 메뉴 막대의 속도를 유지하고 1초에 한 행을 씁니다. 네트워크 확장은 1초마다 짧게 사는 nettop 프로세스를 실행하는 대체 샘플러보다 비용이 적습니다.
diff --git a/site/pages/ko/index.html b/site/pages/ko/index.html index 061471f2..39746da1 100644 --- a/site/pages/ko/index.html +++ b/site/pages/ko/index.html @@ -167,6 +167,7 @@

AI 에이전트가 네트워크를 어떻게 쓰는지 봅니다

소프트웨어를 막아 주는 장벽은 아닙니다. 위협 모델을 보세요.

Cursor ⇥ raw.githubusercontent.com · 4회 거부 · 평일 18:00–09:00

+
⛔ 직접 켜기

관리형 광고 및 추적기 팩

세 가지 공식 팩이 내장되어 있고 처음부터 표시되지만 모두 기본적으로 꺼져 있습니다. Flowlight는 서명된 업데이트가 있는지 하나의 고정 HTTPS 피드를 확인합니다. 새 콘텐츠나 변경된 콘텐츠는 정확한 해당 버전을 검토하고 저장을 누를 때까지 차단 적용에서 제외됩니다. 서비스 중단 중에는 검증되고 승인된 콘텐츠가 오래된 상태임을 명확히 표시한 마지막 정상 버전으로 유지될 수 있습니다.

호스트 이름을 기준으로 차단하므로 자사 광고, DOM이나 레이아웃의 빈 공간, 브라우저 오버레이, 스크립트로 생성된 팝업 또는 네이티브 앱 창은 제거할 수 없습니다.

⛔ 거부

에이전트의 도구에 두는 가드레일

@@ -366,7 +367,7 @@

감시하지 않아도 되는 감시 도구

Mac 안에 머무릅니다

로컬 SQLite 파일 하나. 계정도, 클라우드 동기화도, 텔레메트리도 없습니다.

기본은 메타데이터

호스트 이름과 포트, 바이트 수. HTTPS 복호화는 직접 켜야 하는 별도 모드이며, 그 결과도 Mac 안에 남습니다.

커널에서 걸러냅니다

캡처는 DNS 응답과 TLS ClientHello만 통과시킵니다. 나머지는 Flowlight가 보기 전에 버려집니다.

-
조회는 내가 정합니다

주소의 소유자를 알아낼 때는 호스트 이름이 없는 공용 IP만 Team Cymru로 보내며, 클릭 한 번으로 끌 수 있습니다. 하루 한 번 업데이트 확인은 GitHub에 최신 버전만 물어보고, 이것도 끌 수 있습니다.

+
조회는 내가 정합니다

주소의 소유자를 알아낼 때는 호스트 이름이 없는 공용 IP만 Team Cymru로 보냅니다. 릴리스와 서명된 규칙 팩 확인은 트래픽이나 팩 설정 없이 표준 HTTPS 요청을 보냅니다. 각각 별도로 끌 수 있습니다.

diff --git a/site/pages/ko/privacy.html b/site/pages/ko/privacy.html index 709bb9a6..39982ae1 100644 --- a/site/pages/ko/privacy.html +++ b/site/pages/ko/privacy.html @@ -57,6 +57,7 @@

앱이 내는 네트워크 요청

요청보내는 곳담기는 내용시점 업데이트 확인api.github.com최신 릴리스를 묻는 일반적인 HTTPS 요청. GitHub는 IP 주소와 Flowlight/<version> 사용자 에이전트를 보게 됩니다0.1.2부터, 실행 시와 하루에 한 번. 끄는 스위치는 설정 › 일반 업데이트 다운로드github.com디스크 이미지와 체크섬 파일다운로드 후 열기를 클릭할 때만 + 공식 규칙 팩 업데이트flowlight.xinbetween.com고정된 공식 피드로 보내는 표준 HTTPS 요청입니다. 서버에는 사용자의 IP 주소, Flowlight/<version> 사용자 에이전트 및 조건부 캐시 유효성 검사기가 표시되며, 트래픽, 팩 설정 또는 기기 식별자는 전송되지 않습니다시작 후 확인 시점이 되면 실행되고, 이후 정상 작동 중에는 하루에 최대 한 번 실행됩니다. 기본적으로 켜져 있으며, 규칙 › 팩에서 끄거나 지금 확인을 수동으로 실행할 수 있습니다 역방향 DNS설정된 DNS 리졸버호스트 이름이 관측되지 않았을 때, IP 주소에 등록된 이름 조회필요할 때, 샘플러 캡처 모드에서. 네트워크 확장이 돌기 시작할 때까지는 이것이 기본값입니다. 끄는 스위치는 없습니다. 이것이 없으면 샘플러는 대부분의 연결에 이름을 붙일 수 없습니다. 네트워크 확장은 트래픽 자체에서 이름을 붙이므로 이 조회를 하지 않습니다 네트워크 소유자 조회Team Cymru, DNS로알려진 호스트 이름이 없는 공용 IP 주소. 사설 주소와 예약 주소는 절대 Mac을 벗어나지 않습니다기본값 켬. 끄는 스위치는 캡처 › 네트워크 소유자 확인 호스팅 모델로 질문선택한 제공업체: Anthropic, OpenAI, Google Gemini 또는 호환 엔드포인트내 질문과, 모델이 고른 Flowlight 읽기 전용 질의가 돌려준 결과. 트래픽 데이터베이스 자체는 업로드되지 않습니다호스팅 제공업체를 고르고 질문을 보낼 때만. 질문 화면에 전송된 요청이 표시됩니다 diff --git a/site/pages/ko/threat-model.html b/site/pages/ko/threat-model.html index 0374506f..188dc39c 100644 --- a/site/pages/ko/threat-model.html +++ b/site/pages/ko/threat-model.html @@ -86,34 +86,32 @@

검사가 놓치는 것

어느 쪽인지는 포착 범위가 앱마다 알려줍니다.

차단이 무엇이고 무엇이 아닌지

-

연결을 거부하는 규칙을 실행하는 것은 네트워크 확장이며, 그 경로 안에서, 연결이 만들어지기 전에 실행합니다. URL을 지정하는 - 규칙은 검사 프록시가 대신 실행해, 앱이 읽을 수 있는 상태 코드로 요청에 답합니다. 보고하는 대신 실제로 손을 쓰는 Flowlight의 - 부분은 이 둘뿐입니다. nettop 샘플러는 아무것도 거부할 수 없으므로, 샘플러로 돌아가는 Mac에서는 어떤 규칙도 적용되지 않습니다. - Flowlight는 아무 일도 하지 않는 스위치를 내놓는 대신 그렇다고 말합니다.

+

연결을 거부하는 규칙은 보통 네트워크 확장이 그 경로 안에서, 연결이 만들어지기 전에 실행합니다. HTTPS 검사를 거치는 트래픽은 + 로컬 프록시가 원본 서버 연결을 열기 전에 원래 클라이언트에 대상 규칙과 활성화된 관리형 팩을 적용합니다. URL을 지정하는 규칙도 + 복호화 후 그곳에서 실행되어, 앱이 읽을 수 있는 상태 코드로 응답합니다. 보고하는 대신 실제로 손을 쓰는 Flowlight의 부분은 이 + 둘뿐입니다. nettop 샘플러는 아무것도 거부할 수 없으므로, 샘플러로 돌아가는 Mac에서는 어떤 규칙도 적용되지 않습니다. Flowlight는 + 아무 일도 하지 않는 스위치를 내놓는 대신 그렇다고 말합니다.

그래도 예방은 아닙니다. 셸을 쓸 수 있는 에이전트는 거부된 도구가 했을 일을 직접 할 수 있습니다. 클릭 한 번으로 되돌릴 수 있는 거부는, 에이전트의 다음 시도가 걸어 나갈 수 있는 거부입니다. 로컬 트래픽과 DNS, Apple 기본 서비스, Flowlight 자신의 연결은 절대 거부되지 않습니다. 막힌 Mac은 막힌 에이전트보다 나쁜 결과이기 때문입니다. 그리고 Flowlight가 멈추면 몇 초 뒤에 적용이 풀립니다. 아무도 볼 수 없는 거부는 거부가 없는 것보다 나쁘기 때문입니다.

-

검사와 거부는 함께 가지 않습니다. Flowlight 자신의 프록시를 거친 트래픽은 Flowlight의 트래픽으로 Mac을 - 떠나고, Flowlight는 자기 자신을 거부하지 않습니다. 그러니 요청을 읽고 있는 앱은, 규칙으로는 더 이상 연결을 막을 수 없는 - 앱입니다. 더 보려고 검사를 켜는 일은 적용을 내주는 일이기도 한데, 화면에는 지금까지 그런 말이 없었습니다.

+

검사에서도 호스트 이름에 따른 거부는 유지되지만 경계가 있습니다. Flowlight의 프록시를 거친 트래픽은 예외 처리된 + Flowlight 트래픽으로 Mac을 떠나므로, 프록시가 먼저 원래 앱의 신원을 사용해 일반 대상 규칙과 활성화된 관리형 팩을 결정합니다. + CONNECT가 호스트 이름을 지정하므로 인증서가 고정되었거나 복호화되지 않는 터널에서도 이 판정을 할 수 있습니다. 에이전트 허용 목록은 + 네트워크 확장의 결정으로 남으며 프록시에서 원본 서버로 가는 구간에는 적용되지 않습니다. 신뢰할 수 있는 소유자나 호스트 이름이 없는 + 트래픽은 보이지 않게 거부하지 않고 연결을 허용합니다.

Flowlight 자신이 갖고 있는 것

트래픽 메타데이터를 로컬 SQLite 데이터베이스에, 공개된 보관 기간만큼 갖고 있습니다. HTTPS 검사를 켜 두면 요청과 응답의 본문도 3일 동안 갖고 있는데, 이것이 Flowlight가 보관하는 것 중 가장 민감한 것입니다. 인증 정보 헤더는 이름과 낱말로 가려지지만, 그것은 추정 규칙이고 보장이 아닙니다. 아무것도 알아보지 못하는 이름 아래 비밀을 실어 보내는 헤더는 기록될 수 있습니다.

-

트래픽 데이터베이스는 업로드되지 않습니다. 그래도 묻지 않아도 Mac을 떠나는 것이 세 가지 있고, 셋 다 - 개인정보 페이지에 이름이 적혀 있습니다. 실행할 때와 하루에 한 번 GitHub에 최신 릴리스를 묻는 - 업데이트 확인, 호스트 이름이 없는 공개 주소를 Team Cymru에 묻는 네트워크 소유자 조회, 그리고 Flowlight가 확장 대신 카운터를 - 읽고 있는 동안 이름을 보지 못한 주소에 대한 역방향 DNS 조회입니다. 앞의 둘은 끄는 스위치가 있습니다. 셋째는 없습니다. - 샘플러에서는 연결에 이름이 붙는 길이 대부분 그것이기 때문입니다. 그 조회는 Mac이 이미 쓰는 리졸버로 가고, 확장이 이어받아 - 트래픽 자체에서 이름을 붙이기 시작하면 멈춥니다. 호스팅 모델로 하는 질문은 그 셋 가운데 어느 것보다도 훨씬 많은 것을 - 보내지만, 직접 설정할 때까지 꺼져 있는 유일한 것입니다.

+

트래픽 데이터베이스는 업로드되지 않습니다. 개인정보 페이지에는 릴리스와 서명된 규칙 팩 확인, 선택 사항인 네트워크 소유자 조회, 샘플러의 역방향 DNS 등 모든 외부 요청이 나열됩니다. 앞의 세 항목에는 끄기 스위치가 있습니다. 역방향 DNS는 대부분의 연결에 이름을 붙이는 데 필요해 스위치가 없습니다. 호스팅 모델은 훨씬 더 많은 정보를 보내며 설정하기 전에는 꺼져 있습니다.

업데이트

릴리스는 Developer ID로 서명되고 공증됩니다. 업데이터는 공개된 체크섬을 요구하고 그것이 없는 내려받기를 거부하며, 그다음 설치 전에 내려받은 앱이 실행 중인 사본과 같은 팀의 서명인지 확인합니다. 다른 사람이 서명한 업데이트는 나머지가 다 유효해도 거부됩니다.

+

공식 규칙 팩은 용도를 한정한 별도의 서명과 하나의 고정 HTTPS 소스를 사용합니다. 앱은 독립적인 최고 시퀀스 워터마크를 유지하고, 시작할 때 캐시된 정확한 바이트를 다시 검증하며, 롤백과 서로 상충하는 콘텐츠의 제시를 거부합니다. 또한 게시자 서명을 변경된 콘텐츠의 차단 적용을 허용하는 권한으로 취급하지 않습니다. 새 팩이나 변경된 팩에는 정확한 콘텐츠를 명시적으로 검토하고 저장하는 절차가 필요합니다. 오래된 승인 콘텐츠에는 마지막 정상 버전이라는 표시가 붙으며, 신뢰가 취소되었거나 바이트가 손상된 경우에는 안전을 위해 거부됩니다.

취약점 신고하기

GitHub의 비공개 취약점 신고를 이용하시고, GitHub를 쓰고 diff --git a/site/pages/privacy.html b/site/pages/privacy.html index 3dbedb11..49244612 100644 --- a/site/pages/privacy.html +++ b/site/pages/privacy.html @@ -63,6 +63,7 @@

Network requests the app makes

RequestSent toWhat it containsWhen Update checkapi.github.comA standard HTTPS request for the latest release; GitHub sees your IP address and the Flowlight/<version> user agentAfter a clean quit and relaunch, and daily. Off switch in Settings › General Update downloadgithub.comThe disk image and its checksum fileOnly when you click Download & Open + Official rule-pack updateflowlight.xinbetween.comA standard HTTPS request to the fixed official feed for a publisher-signed catalog. The server sees your IP address, the Flowlight/<version> user agent, and conditional cache validators; it receives no traffic, pack settings, or device identifierAfter startup when due and then at most daily while healthy. On by default; off switch and manual Check Now in Rules › Packs Reverse DNSYour configured DNS resolverA lookup of an IP address's registered name, when no hostname was seenAs needed, in the sampler capture mode, which is the default until the Network Extension is running. No off switch: without it the sampler cannot name most connections. The Network Extension names them from the traffic itself and does not do this Network owner lookupTeam Cymru, over DNSPublic IP addresses with no known hostname. Private and reserved addresses never leave your MacOn by default. Off switch in Capture › Identify network owners Ask with a hosted modelThe provider you select: Anthropic, OpenAI, Google Gemini or a compatible endpointYour question and the results returned by the read-only Flowlight queries selected by the model. The traffic database itself is not uploadedOnly when you select a hosted provider and submit a question. The Ask screen shows the transmitted requests diff --git a/site/pages/pt-PT/docs.html b/site/pages/pt-PT/docs.html index bca76cb7..8f61b337 100644 --- a/site/pages/pt-PT/docs.html +++ b/site/pages/pt-PT/docs.html @@ -383,6 +383,11 @@

Regras

é segura. O ecrã indica também o tráfego mais antigo que conseguiu ver: uma regra testada contra duas horas de histórico foi testada contra duas horas de histórico.

+

Pacotes geridos de publicidade e rastreadores

+

Regras › Pacotes contém três listas oficiais de nomes de anfitrião: Publicidade, Rastreio entre sites e Privacidade e telemetria. Estão visíveis desde o início e todas desativadas por predefinição. O bloqueio requer a Extensão de rede; o amostrador nettop apenas pode observar.

+

Apenas atualizações oficiais e assinadas. O Flowlight consulta uma única fonte HTTPS fixa para procurar catálogos mais recentes assinados pelo editor. As verificações automáticas estão ativas por predefinição e podem ser desativadas aqui. Uma assinatura autentica o conteúdo, mas não o ativa: um novo pacote permanece desativado e, se os destinos forem alterados, um pacote ativo deixa de ser aplicado até que reveja a nova versão exata e prima Guardar. Durante uma interrupção, o conteúdo aceite pode permanecer disponível como a última versão válida conhecida, visivelmente assinalada como desatualizada. Estes são bloqueios de rede ao nível do nome de anfitrião: não podem remover espaços vazios no DOM ou no esquema, sobreposições do navegador, janelas pop-up geradas por scripts ou janelas de aplicações nativas.

+

Quando a inspeção HTTPS encaminha uma app pelo proxy do Flowlight, este decide as regras de destino comuns e os pacotes geridos ativos com base na identidade original da app antes de abrir qualquer ligação ao servidor de origem. Nos túneis CONNECT com certificado fixado ou que nunca são decifrados, isto funciona pelo nome de anfitrião; as listas de permissões dos agentes continuam limitadas ao caminho direto pela Extensão de rede. Se o proprietário ou o nome de anfitrião for desconhecido, a ligação é permitida.

+

Listas de permissões

Selecione um agente em Agentes de IA e use a Lista de permissões no seu detalhe para dizer onde pode ligar-se. Introduza domínios (subdomínios incluídos), endereços IP ou intervalos CIDR, ou escolha uma predefinição: GitHub, npm, PyPI, Homebrew, Docker Hub, Rust crates, módulos de Go ou @@ -401,8 +406,7 @@

Recusar ligações

Flowlight. Um Mac bloqueado é um resultado pior do que um agente bloqueado, por isso, na dúvida, a ligação é permitida e registada. A aplicação efetiva também caduca uns segundos depois de o Flowlight deixar de correr — uma recusa que ninguém consegue ver é pior do que nenhuma, e a maneira de a desfazer está na app.

-

A inspeção HTTPS e o bloqueio não se combinam: o tráfego encaminhado pelo proxy do Flowlight sai como sendo do próprio Flowlight, que - nunca é recusado.

+

Para uma app encaminhada pela inspeção HTTPS, o proxy do Flowlight decide as regras de destino comuns e os pacotes geridos ativos com base na identidade original da app antes de abrir qualquer ligação ao servidor de origem. Nos túneis CONNECT com certificado fixado ou que nunca são decifrados, fá-lo pelo nome de anfitrião. As listas de permissões dos agentes continuam limitadas ao caminho direto pela Extensão de rede; se o proprietário ou o nome de anfitrião for desconhecido, a ligação é permitida.

Proteções dos agentes: bloquear uma ferramenta, um servidor ou um recurso

Que ferramentas pode este agente usar é uma pergunta diferente de que anfitriões pode alcançar, por isso tem a sua própria @@ -616,10 +620,7 @@

Perguntas e respostas

Mostra que extensões estão instaladas, se o macOS realmente iniciou o filtro do Flowlight e há quanto tempo foi registado algo. Cole isso num problema.
O Flowlight envia algo sobre mim para algum lado?
-
Não há conta, nem telemetria, nem analítica, e a sua base de dados de tráfego nunca é enviada. Três pedidos saem do Mac, todos listados na - página de privacidade: a procura diária de atualizações e a consulta opcional do proprietário da rede, ambas com - interruptores para desativar, e o DNS inverso enquanto o Flowlight está no amostrador, que não tem nenhum — sem ele, o amostrador não conseguiria - dar nome à maioria das ligações.
+
Não há conta, nem telemetria, nem analítica, e a sua base de dados de tráfego nunca é enviada. A página de privacidade lista cada pedido: as verificações de versão e de pacotes de regras assinados, além da consulta opcional do proprietário da rede, têm interruptores; o DNS inverso só funciona com o amostrador e não tem um, porque sem ele a maioria das ligações ficaria sem nome.
Vai tornar o meu Mac mais lento ou gastar a bateria?
Com a janela fechada, o Flowlight quase não faz nada: mantém as taxas na barra de menus e escreve uma linha por segundo. A extensão de rede custa menos do que o amostrador de reserva, que inicia um processo nettop de vida curta a cada segundo.
diff --git a/site/pages/pt-PT/index.html b/site/pages/pt-PT/index.html index fc4372b8..229a706a 100644 --- a/site/pages/pt-PT/index.html +++ b/site/pages/pt-PT/index.html @@ -167,6 +167,7 @@

Acompanhe como os agentes de IA usam a rede

software em que não confia — veja o modelo de ameaças.

Cursor ⇥ raw.githubusercontent.com · recusado 4 vezes · dias de semana 18:00–09:00

+
⛔ Por opção

Pacotes geridos de publicidade e rastreadores

Estão integrados três pacotes oficiais, visíveis e desativados por predefinição. O Flowlight consulta um único feed HTTPS fixo para obter atualizações assinadas; os conteúdos novos ou alterados ficam fora da aplicação até rever essa versão exata e premir Guardar. Durante uma indisponibilidade, os conteúdos verificados e aceites podem continuar disponíveis como a última versão válida conhecida, visivelmente assinalada como desatualizada.

Bloqueiam por nome de anfitrião, pelo que não conseguem remover anúncios próprios, espaços vazios no DOM ou no esquema da página, sobreposições do navegador, janelas emergentes criadas por scripts nem janelas nativas das aplicações.

⛔ Recusa

Proteções nas ferramentas de um agente

@@ -366,7 +367,7 @@

Um monitor que não tem de monitorizar

Fica no seu Mac

Um único ficheiro SQLite local. Sem conta, sem sincronização na nuvem, sem telemetria.

Metadados por predefinição

Nomes de anfitrião, portas e contagens de bytes. Desencriptar HTTPS é um modo separado que tem de ativar, e fica tudo no seu Mac.

Filtrado no núcleo

A captura só deixa passar as respostas de DNS e os ClientHello de TLS. Todo o resto é descartado antes de o Flowlight o ver.

-
Consultas sob o seu controlo

Saber quem é o proprietário de um endereço envia à Team Cymru apenas IPs públicos sem nome de anfitrião, e desativa-se num clique. A procura diária de atualizações só pergunta ao GitHub pela versão mais recente e também pode ser desativada.

+
Consultas sob o seu controlo

Saber quem é o proprietário de um endereço envia à Team Cymru apenas IPs públicos sem nome de anfitrião. As verificações de versões e de pacotes de regras assinados enviam pedidos HTTPS normais, sem tráfego nem definições dos pacotes. Cada uma tem o seu próprio interruptor.

diff --git a/site/pages/pt-PT/privacy.html b/site/pages/pt-PT/privacy.html index 30efea15..44189239 100644 --- a/site/pages/pt-PT/privacy.html +++ b/site/pages/pt-PT/privacy.html @@ -56,6 +56,7 @@

Pedidos de rede que a app faz

PedidoEnviado paraO que contémQuando Procura de atualizaçõesapi.github.comUm pedido HTTPS normal pela versão mais recente; o GitHub vê o seu endereço IP e o agente de utilizador Flowlight/<version>Na abertura e uma vez por dia, a partir da 0.1.2. Interruptor para desativar em Definições › Geral Transferência da atualizaçãogithub.comA imagem de disco e o seu ficheiro de soma de verificaçãoSó quando clica em Transferir e abrir + Atualização oficial do pacote de regrasflowlight.xinbetween.comUm pedido HTTPS padrão ao feed oficial fixo. O servidor vê o seu endereço IP, o agente do utilizador Flowlight/<version> e os validadores condicionais de cache; não recebe tráfego, definições dos pacotes nem qualquer identificador do dispositivoApós o arranque, quando for necessário, e depois, no máximo, uma vez por dia enquanto funcionar normalmente. Ativa por predefinição; opção para desativar e Procurar agora manualmente em Regras › Pacotes DNS inversoO resolvedor de DNS que tem configuradoUma consulta do nome registado de um endereço IP, quando não foi visto nenhum nome de anfitriãoConforme necessário, no modo de captura por amostrador, que é a predefinição até a extensão de rede estar a correr. Sem interruptor para desligar: sem isto, o amostrador não consegue dar nome à maioria das ligações. A extensão de rede dá-lhes nome a partir do próprio tráfego e não faz esta consulta Consulta do proprietário da redeTeam Cymru, por DNSEndereços IP públicos sem nome de anfitrião conhecido. Os endereços privados e reservados nunca saem do seu MacAtivada por predefinição. Interruptor para desativar em Captura › Identificar os proprietários da rede Perguntar com um modelo alojadoO fornecedor que escolher: Anthropic, OpenAI, Google Gemini ou um endpoint compatívelA sua pergunta e os resultados devolvidos pelas consultas de leitura do Flowlight escolhidas pelo modelo. A base de dados de tráfego em si não é enviadaSó quando escolhe um fornecedor alojado e submete uma pergunta. O ecrã Perguntar mostra os pedidos transmitidos diff --git a/site/pages/pt-PT/threat-model.html b/site/pages/pt-PT/threat-model.html index 2e43f74d..c3fbe684 100644 --- a/site/pages/pt-PT/threat-model.html +++ b/site/pages/pt-PT/threat-model.html @@ -86,36 +86,33 @@

O que escapa à inspeção

coisas se aplica, app por app.

O que o bloqueio é, e não é

-

Uma regra que recusa ligações é levada a cabo pela extensão de rede, no caminho, antes de a ligação ser feita. Uma regra que nomeia um URL é - levada a cabo pelo proxy de inspeção, que responde ao pedido com um estado que a app consegue ler. São as duas únicas partes do Flowlight que - agem em vez de relatar — o amostrador nettop não consegue recusar nada, por isso num Mac que capta com o amostrador nenhuma regra é aplicada, e - o Flowlight di-lo em vez de oferecer um interruptor que não faz nada.

+

Uma regra que recusa ligações é normalmente levada a cabo pela extensão de rede, no caminho, antes de a ligação ser feita. Para o tráfego + encaminhado pela inspeção HTTPS, o proxy local aplica as regras de destino e os pacotes geridos ativos ao cliente original antes de abrir a + ligação ao servidor de origem; uma regra que nomeia um URL também é aplicada ali, depois da desencriptação, com um estado que a app consegue ler. + São as duas únicas partes do Flowlight que agem em vez de relatar — o amostrador nettop não consegue recusar nada, por isso num Mac que capta com + o amostrador nenhuma regra é aplicada, e o Flowlight di-lo em vez de oferecer um interruptor que não faz nada.

Continua a não ser impedir. Um agente que consiga executar uma shell pode fazer à mão o que a ferramenta recusada teria feito. Uma recusa que pode desfazer num clique é uma recusa por onde a tentativa seguinte de um agente pode passar. O tráfego local, o DNS, os serviços da Apple e as ligações do próprio Flowlight nunca são recusados, porque um Mac bloqueado é um resultado pior do que um agente bloqueado. E a aplicação efetiva caduca uns segundos depois de o Flowlight deixar de correr: uma recusa que ninguém consegue ver é pior do que nenhuma.

-

A inspeção e a recusa não se combinam. O tráfego encaminhado pelo próprio proxy do Flowlight sai do Mac como tráfego do - Flowlight, e o Flowlight nunca se recusa a si mesmo — por isso uma app cujos pedidos está a ler é uma app cujas ligações as regras já não - travam. Ligar a inspeção para ver mais custa-lhe a aplicação das regras, e até agora nada no ecrã o dizia.

+

A inspeção preserva a recusa por nome de anfitrião, com um limite. O tráfego encaminhado pelo proxy do Flowlight sai do Mac como + tráfego isento do Flowlight, por isso o proxy decide primeiro as regras de destino comuns e os pacotes geridos ativos com base na identidade + original da app. Consegue fazê-lo mesmo em túneis com certificado fixado ou nunca decifrados, porque CONNECT indica o anfitrião. As listas de + permissões dos agentes continuam a ser uma decisão da Extensão de rede e não se aplicam a esse trajeto pelo proxy até ao servidor de origem; o + tráfego sem um proprietário fiável ou sem nome de anfitrião é permitido em vez de ser recusado sem que ninguém o veja.

O que o próprio Flowlight guarda

Metadados de tráfego numa base de dados SQLite local, com uma retenção publicada. Com a inspeção HTTPS ligada, guarda também os corpos dos pedidos e das respostas durante três dias — a coisa mais sensível que chega a manter. Os cabeçalhos de credenciais são ocultados por nome e por palavra, o que é uma heurística e não uma garantia; um cabeçalho que leve um segredo sob um nome que nada reconhece pode ficar registado.

-

A base de dados de tráfego nunca é enviada. Há, ainda assim, três coisas mais pequenas que saem do Mac sem lhe serem pedidas, - todas nomeadas na página de privacidade: a procura de atualizações, que pede ao GitHub a versão mais - recente no arranque e uma vez por dia; a consulta do proprietário da rede, que pergunta ao Team Cymru por endereços públicos sem - nome de anfitrião; e, enquanto o Flowlight lê contadores em vez de correr a sua extensão, uma consulta de DNS inverso dos endereços - para os quais não viu nome. As duas primeiras têm interruptores para desligar. A terceira não tem, porque no amostrador é quase - tudo aquilo que dá um nome a uma ligação — vai para o resolvedor de DNS que o Mac já usa e para quando a extensão assume e passa a - nomear as ligações a partir do próprio tráfego. Perguntar com um modelo alojado envia muito mais do que qualquer uma delas, e é a - única coisa que fica desligada até a configurar.

+

A base de dados de tráfego nunca é enviada. A página de privacidade lista cada pedido externo: verificações de versão e de pacotes de regras assinados, consulta opcional do proprietário da rede e DNS inverso com o amostrador. Os três primeiros têm interruptores; o DNS inverso não, porque sem ele o amostrador não conseguiria nomear a maioria das ligações. Um modelo alojado envia muito mais e fica desligado até o configurar.

Atualizações

As versões são assinadas com um Developer ID e notarizadas. O atualizador exige uma soma de verificação publicada e recusa uma transferência sem ela; depois verifica que a app transferida está assinada pela mesma equipa que a cópia em execução antes de a instalar. Uma atualização assinada por outra pessoa é recusada mesmo que no resto seja válida.

+

Os pacotes de regras oficiais usam uma assinatura distinta e limitada a esta finalidade e uma única fonte HTTPS fixa. A aplicação mantém separadamente um marcador da sequência mais alta, volta a verificar no arranque os bytes exatos guardados em cache, rejeita regressões e publicações contraditórias e nunca trata a assinatura do editor como autorização para aplicar conteúdos alterados. Os pacotes novos ou alterados exigem a revisão explícita do conteúdo exato e Guardar; os conteúdos desatualizados já aceites são assinalados como a última versão válida conhecida, enquanto a revogação da confiança ou bytes corrompidos fazem com que o conteúdo seja recusado por segurança.

Comunicar uma vulnerabilidade

Use a comunicação privada de vulnerabilidades do GitHub, ou envie um email diff --git a/site/pages/releases.html b/site/pages/releases.html index 49aa71ff..87a9532f 100644 --- a/site/pages/releases.html +++ b/site/pages/releases.html @@ -13,7 +13,28 @@

What's new

-

0.15.3

Latest
+

0.15.4

Latest
+
    +
  • Consume verified official rule-pack updates. Flowlight checks its one fixed HTTPS feed with conditional + requests, verifies publisher signatures and compatibility before persistence, and keeps exact last-known-good bytes plus + an independent anti-rollback watermark.
  • +
  • Keep every policy change consentful. New packs stay off. Changed versions or destinations immediately leave + enforcement while preserving scope, selected apps, exceptions and history; explicit review and Save of the exact new content + restores consent, and a refresh racing an open editor makes its old Save stale.
  • +
  • Expose trust and failure state without overclaiming. Rules › Packs now shows automatic-refresh controls, + verification status, sequence, source, signer, timestamps, bundled-versus-downloaded provenance, stale last-known-good, and + independent fetch/apply failures. Refresh sends no traffic, pack settings or device identifier.
  • +
  • Fail closed at startup and on the wire. Capture waits for catalog verification plus saved exceptions; + redirects, MIME, size, timeout, rollback, equivocation, revoked trust, corrupt cache and failed database commits cannot publish + unseen definitions or replace the active snapshot.
  • +
  • Keep managed packs enforcing during HTTPS inspection. Before the local proxy opens a CONNECT tunnel or + plaintext origin connection, it applies the extension-acknowledged pack snapshot to the original app identity. A refusal is + returned only after its activity record commits; failed persistence allows the connection instead of blocking invisibly.
  • +
+
+ +
+

0.15.3

  • Publish official rule packs as signed data. Flowlight now defines a purpose-scoped P-256 signature contract for its official managed-pack catalog, with the public verification key included in the diff --git a/site/pages/threat-model.html b/site/pages/threat-model.html index 74d3ad73..d42ef6c5 100644 --- a/site/pages/threat-model.html +++ b/site/pages/threat-model.html @@ -86,34 +86,41 @@

    What inspection misses

    per app.

    What blocking is, and isn't

    -

    A rule that refuses a connection is carried out by the Network Extension, in the path, before the connection is made. A rule - that names a URL is carried out by the inspection proxy instead, which answers the request with a status the app can read. - Those are the only two parts of Flowlight that act rather than report — the nettop sampler can refuse nothing at all, so on a - Mac running the sampler no rule is enforced, and Flowlight says so rather than offering a switch that does nothing.

    +

    A rule that refuses a connection is normally carried out by the Network Extension, in the path, before the connection is made. + For traffic routed through HTTPS inspection, the local proxy applies destination rules and enabled managed packs against the + original client before it opens the upstream connection; a rule that names a URL is also carried out there, after decryption, + with a status the app can read. Those are the only two parts of Flowlight that act rather than report — the nettop sampler can + refuse nothing at all, so on a Mac running the sampler no rule is enforced, and Flowlight says so rather than offering a switch + that does nothing.

    It is still not prevention. An agent that can run a shell can do by hand what a refused tool would have done. A refusal you can undo in one click is a refusal an agent's next attempt may walk through. Local traffic, DNS, Apple's own services and Flowlight's own connections are never refused, because a blocked Mac is a worse outcome than a blocked agent. And enforcement lapses a few seconds after Flowlight stops running: a refusal nobody can see is worse than none.

    -

    Inspection and refusal do not combine. Traffic routed through Flowlight's own proxy leaves the Mac as - Flowlight's traffic, and Flowlight never refuses itself — so an app whose requests you are reading is an app whose connections - rules no longer stop. Turning inspection on to see more costs you enforcement, and nothing on screen previously said so.

    +

    Inspection preserves hostname refusal, with a boundary. Traffic routed through Flowlight's proxy leaves the + Mac as exempt Flowlight traffic, so the proxy decides ordinary destination rules and enabled managed packs first, using the + original app identity. It can do this even for pinned or never-decrypted tunnels because CONNECT names the host. Agent allowlists + remain a Network Extension decision and do not apply to that proxied upstream leg; traffic with no trustworthy owner or no + hostname fails open rather than being refused invisibly.

    What Flowlight itself holds

    Traffic metadata in a local SQLite database, with published retention. With HTTPS inspection on it also holds request and response bodies for three days — the most sensitive thing it ever keeps. Credential headers are redacted by name and by word, which is a heuristic and not a guarantee; a header carrying a secret under a name nothing recognises can be recorded.

    -

    The traffic database is never uploaded. Three smaller things do leave the Mac without being asked for, all of them named on - the privacy page: the update check, which asks GitHub for the latest release on launch and - daily; the network-owner lookup, which asks Team Cymru about public addresses that have no hostname; and, while Flowlight is - reading counters instead of running its extension, a reverse-DNS lookup of addresses it saw no name for. The first two have - off switches. The third does not, because on the sampler it is most of how a connection gets a name at all — it goes to the - resolver the Mac already uses, and it stops once the extension takes over and names connections from the traffic itself. - Ask with a hosted model sends far more than any of them, and is the one thing that stays off until you configure it.

    +

    The traffic database is never uploaded. Four smaller things do leave the Mac without being asked for, all of them named on + the privacy page: the release update check; the fixed official rule-pack feed, which receives + no traffic or pack settings; the network-owner lookup, which asks Team Cymru about public addresses with no hostname; and, + while Flowlight is reading counters instead of running its extension, reverse DNS through the Mac's configured resolver. + The first three have off switches. Reverse DNS does not, because on the sampler it is most of how a connection gets a name; + it stops once the extension takes over. Ask with a hosted model sends far more than any of them, and stays off until configured.

    Updates

    Releases are signed with a Developer ID and notarized. The updater requires a published checksum and refuses a download without one, then verifies that the downloaded app is signed by the same team as the running copy before installing it. An update signed by somebody else is refused even if it is otherwise valid.

    +

    Official rule packs use a separate purpose-scoped P-256 signature and one compiled HTTPS source. The app keeps an independent + highest-sequence watermark, reverifies exact cached bytes at launch, rejects rollback and equivocation, and never treats a + publisher signature as permission to enforce changed content. New or changed packs require explicit exact-content review and + Save; stale accepted content is labeled last-known-good, while revoked trust or corrupt bytes fail closed.

    Reporting a vulnerability

    Use GitHub's private vulnerability reporting, or email diff --git a/site/pages/zh-Hans/docs.html b/site/pages/zh-Hans/docs.html index f1f6791f..e39d0f16 100644 --- a/site/pages/zh-Hans/docs.html +++ b/site/pages/zh-Hans/docs.html @@ -346,6 +346,11 @@

    规则

    可供对照”——同一张空列表,意思正好相反,而其中只有一个说这条规则是安全的。界面还会说出它能看到 的最早的流量:一条只对照了两小时历史的规则,就是只对照了两小时历史。

    +

    托管广告和跟踪器包

    +

    规则 › 包包含三个官方主机名列表:广告、跨站跟踪和隐私与遥测。它们从一开始就可见,并且默认均为关闭状态。阻止功能需要网络扩展;nettop 采样器只能观察。

    +

    仅限官方签名更新。 Flowlight 会检查一个固定的 HTTPS 源,以查找由发布者签名的新版目录。自动检查默认开启,可在此处关闭。签名可验证内容,但不会启用内容:新包会保持关闭;如果目标发生变化,已启用的包将停止执行,直到你审查确切的新版本并按下保存。服务中断期间,已接受的内容可能会继续作为最后已知正常版本保留,并明确显示为已过期。这些是主机名级别的网络阻止,无法移除 DOM 或布局中的空白、浏览器叠加层、脚本生成的弹出窗口或原生应用窗口。

    +

    当 HTTPS 检查将应用经由 Flowlight 的代理转发时,普通目标规则和已启用的托管包会在打开任何源站连接之前,使用原应用的身份作出判定。对于固定了证书或始终不解密的 CONNECT 隧道,这一判定按主机名进行;代理允许列表仍只适用于直接经过网络扩展的路径。如果所有者或主机名未知,则会允许连接。

    +

    允许列表

    在AI 代理里选中一个代理,用它详情里的允许列表指定它可以连接哪里。输入域名 (含子域名)、IP 地址或 CIDR 地址段,或者选一个预设:GitHub、npm、PyPI、Homebrew、Docker Hub、Rust crates、Go modules 或 @@ -364,8 +369,7 @@

    拒绝连接

    流量。一台被封住的 Mac 比一个被封住的代理更糟,所以有疑问时,连接会被允许并记录下来。 Flowlight 停止运行几秒之后,强制执行也会失效——没人能看见的拒绝比没有拒绝更糟,而撤销它的 办法就在应用里。

    -

    HTTPS 检查和阻止不能并用:经由 Flowlight 代理转发的流量是以 Flowlight 自己的名义发出的,而它 - 永远不会被拒绝。

    +

    对于经由 HTTPS 检查转发的应用,Flowlight 的代理会在打开任何源站连接之前,使用原应用的身份,对普通目标规则和已启用的托管包作出判定。对于固定了证书或始终不解密的 CONNECT 隧道,这一判定按主机名进行。代理允许列表仍只适用于直接经过网络扩展的路径;如果所有者或主机名未知,则会允许连接。

    代理护栏:阻止一个工具、一个服务器或一个资源

    这个代理可以用哪些工具和它可以访问哪些主机是两个不同的问题,所以它有自己的 @@ -573,9 +577,7 @@

    问题与解答

    它会显示安装了哪些扩展、macOS 是否真的启动了 Flowlight 的过滤器,以及最近一次记录是什么时候。 把它粘贴到一个 issue 里。
    Flowlight 会把关于我的任何信息发到什么地方吗?
    -
    没有账号,没有遥测,也没有分析,而且你的流量数据库从不上传。确实有三个请求会离开这台 Mac,它们都列在隐私页面上: - 每日更新检查和可选的网络所有者查询,这两个都有开关可以关掉;以及 Flowlight 在采样器上运行时的反向 DNS,它没有开关—— - 没有它,采样器就没法给大多数连接命名。
    +
    没有账号,没有遥测,也没有分析,而且你的流量数据库从不上传。隐私页面列出了每项请求:版本和签名规则包检查以及可选的网络所有者查询都有关闭开关;反向 DNS 只在采样器模式运行且没有开关,因为没有它,大多数连接将无法命名。
    它会让我的 Mac 变慢,或者更耗电吗?
    窗口关闭时 Flowlight 几乎什么都不做:它保留菜单栏速率,每秒写入一行。网络扩展 比作为后备的采样器更省,后者每秒都要启动一个短命的 nettop 进程。
    diff --git a/site/pages/zh-Hans/index.html b/site/pages/zh-Hans/index.html index d761ca61..0dac38be 100644 --- a/site/pages/zh-Hans/index.html +++ b/site/pages/zh-Hans/index.html @@ -166,6 +166,7 @@

    看住 AI 代理如何使用网络

    它是对你本来就信任的软件的管控,不是一道拦住你不信任的软件的屏障——见威胁模型。

    Cursor ⇥ raw.githubusercontent.com · 已拒绝 4 次 · 工作日 18:00–09:00

+
⛔ 需自行开启

托管广告和跟踪器包

三个官方包均已内置,从一开始就可见,但默认全部关闭。Flowlight 会从一个固定的 HTTPS 源检查签名更新;新增或变更的内容在你审查确切版本并按下保存之前,不会进入强制执行。服务中断期间,经过验证并已接受的内容可能会作为最后已知正常版本保留,并明确标示为已过期。

这些包按主机名阻止,因此无法移除第一方广告、DOM 或布局空白、浏览器叠加层、脚本生成的弹窗或原生应用窗口。

⛔ 拒绝

给代理的工具加护栏

@@ -363,7 +364,7 @@

一个不用你盯着的监控工具

只留在你的 Mac 上

一个本地 SQLite 文件。没有账号,不做云同步,不收遥测。

默认只看元数据

主机名、端口和字节数。解密 HTTPS 是另一个要你自己打开的模式,而且同样只在你的 Mac 上。

在内核里就筛掉

捕获只放行 DNS 应答和 TLS ClientHello。其余一切在 Flowlight 看到之前就被丢掉了。

-
查询由你掌控

要查出某个地址归谁所有,只会把没有主机名的公网 IP 发给 Team Cymru,一键即可关闭。每日更新检查只向 GitHub 询问最新版本,同样可以关掉。

+
查询由你掌控

要查出某个地址归谁所有,只会把没有主机名的公网 IP 发给 Team Cymru。版本和签名规则包检查会发送不含流量或规则包设置的标准 HTTPS 请求。两项均有各自的关闭开关。

diff --git a/site/pages/zh-Hans/privacy.html b/site/pages/zh-Hans/privacy.html index ba14ce39..48aa7766 100644 --- a/site/pages/zh-Hans/privacy.html +++ b/site/pages/zh-Hans/privacy.html @@ -55,6 +55,7 @@

应用发出的网络请求

请求发往包含什么什么时候 更新检查api.github.com一个查询最新版本的标准 HTTPS 请求;GitHub 会看到你的 IP 地址和 Flowlight/<version> 这个 User-Agent 标识启动时以及每天一次,自 0.1.2 起。关闭开关在设置 › 通用 更新下载github.com磁盘映像及其校验和文件只在你点击“下载并打开”时 + 官方规则包更新flowlight.xinbetween.com向固定官方源发送的标准 HTTPS 请求。服务器会看到你的 IP 地址、Flowlight/<version>用户代理和缓存条件验证器;不会收到任何流量、包设置或设备标识符启动后在检查到期时执行,此后在运行正常的情况下最多每天执行一次。默认开启;可在规则 › 包中关闭或手动选择立即检查 反向 DNS你配置的 DNS 解析器在没有看到主机名时,查询某个 IP 地址登记的名称按需进行,在采样器捕获模式下,也就是网络扩展开始运行之前的默认模式。没有关闭开关:没有它,采样器就没法给大多数连接命名。网络扩展从流量本身给它们命名,因此不做这件事 网络所有者查询Team Cymru,通过 DNS没有已知主机名的公网 IP 地址。私有地址和保留地址绝不离开你的 Mac默认开启。关闭开关在捕获 › 识别网络所有者 用托管模型提问你选择的提供方:Anthropic、OpenAI、Google Gemini 或兼容的端点你的问题,以及模型选中的那些只读 Flowlight 查询所返回的结果。流量数据库本身不会被上传只在你选择了托管提供方并提交问题时。提问界面会显示发送出去的请求 diff --git a/site/pages/zh-Hans/threat-model.html b/site/pages/zh-Hans/threat-model.html index 4736f1d9..f5a4a9ef 100644 --- a/site/pages/zh-Hans/threat-model.html +++ b/site/pages/zh-Hans/threat-model.html @@ -79,31 +79,29 @@

检查会漏掉什么

所以,检查界面是空的,意思是“没有东西被转发到这里”,而不是“什么都没发生”。到底是哪一种,覆盖范围会按应用逐个说明。

阻止是什么,不是什么

-

一条拒绝连接的规则由网络扩展执行,就在连接的路径上,在连接建立之前。一条指名 URL 的规则则由检查用的代理服务器执行, - 它用应用能读到的状态来回答那个请求。这两处是 Flowlight 里唯一动手而不只是报告的部分——nettop 采样器什么都拒绝不了, - 所以在用采样器的 Mac 上,没有任何规则会被强制执行,而 Flowlight 会把这一点说出来,而不是摆一个什么都不做的开关。

+

一条拒绝连接的规则通常由网络扩展执行,就在连接的路径上,在连接建立之前。对于经由 HTTPS 检查转发的流量,本地代理会在 + 打开源站连接之前,针对原客户端应用目标规则和已启用的托管包;一条指名 URL 的规则也会在解密后由它执行,并用应用能读到的 + 状态作出响应。这两处是 Flowlight 里唯一动手而不只是报告的部分——nettop 采样器什么都拒绝不了,所以在用采样器的 Mac 上, + 没有任何规则会被强制执行,而 Flowlight 会把这一点说出来,而不是摆一个什么都不做的开关。

它仍然不是防护。一个能运行 shell 的代理,可以手动做到那个被拒绝的工具本来要做的事。一次你能一键撤销的拒绝, 也是代理下一次尝试就可能走过去的拒绝。本地流量、DNS、Apple 自家的服务以及 Flowlight 自己的连接永远不会被拒绝, 因为一台被封住的 Mac 比一个被封住的代理更糟。而且 Flowlight 停止运行几秒之后,强制执行就会失效: 没人能看见的拒绝比没有拒绝更糟。

-

检查与拒绝无法并存。经由 Flowlight 自己的代理服务器转发的流量,是以 Flowlight 的流量的身份离开这台 Mac 的, - 而 Flowlight 从不拒绝自己——所以一个你正在读它请求的应用,也就是一个规则不再拦得住它连接的应用。为了看得更多而打开检查, - 代价就是强制执行,而画面上此前并没有说这一点。

+

检查仍能按主机名拒绝连接,但有一条边界。经由 Flowlight 代理转发的流量,会作为豁免的 Flowlight 流量离开 + Mac,因此代理会先使用原应用的身份,对普通目标规则和已启用的托管包作出判定。即使隧道固定了证书或始终不解密,也能这样判定, + 因为 CONNECT 会指明主机。代理允许列表仍由网络扩展判定,不适用于从代理到源站的这一段路径;没有可信所有者或主机名的流量会 + 被允许,而不会在无人察觉的情况下遭到拒绝。

Flowlight 自己保存了什么

流量元数据,存在本地的 SQLite 数据库里,保留期限是公布的。打开 HTTPS 检查后,它还会保存请求和响应的正文三天—— 这是它所保存的东西里最敏感的。凭据标头会按名称、也按其中的词来遮蔽,那是一种启发式判断,不是保证; 一个把密钥放在没人认得的名字底下的标头,是可能被记录下来的。

-

流量数据库从不上传。不过确实有三样比较小的东西,会在你没有要求的情况下离开这台 Mac,它们都在隐私页面上 - 逐项列出:更新检查,它在启动时和每天一次向 GitHub 询问最新的发布;网络所有者查询,它就没有主机名的公网地址向 Team Cymru - 询问;以及,当 Flowlight 在读计数器而不是在跑它的扩展时,对那些它没看到名字的地址做的一次反向 DNS 查询。前两样都有关闭 - 开关。第三样没有,因为在采样器上,一条连接能有名字,大半就靠它——这次查询会走这台 Mac 本来就在用的解析器,而一旦扩展 - 接手、从流量本身给连接命名,它就停了。用托管模型提问,送出去的东西比它们任何一个都多得多,而它也是唯一在你配置之前 - 一直关着的。

+

流量数据库从不上传。隐私页面列出了每项外部请求:版本和签名规则包检查、可选的网络所有者查询,以及采样器的反向 DNS。前三项都有关闭开关;反向 DNS 没有,因为没有它,采样器无法为大多数连接命名。托管模型会发送更多内容,并且在配置前保持关闭。

更新

版本以 Developer ID 签名并经过公证。更新程序要求有公布的校验和,没有就拒绝下载;随后会在安装之前核对下载到的应用 是否由与运行中副本相同的团队签名。由别人签名的更新会被拒绝,即使它在其他方面都有效。

+

官方规则包使用一套用途受限的独立签名和一个固定的 HTTPS 源。应用会独立保存最高序列号水位,在启动时重新验证缓存的确切字节,拒绝回滚和相互矛盾的发布,并且绝不会把发布者签名当作强制执行变更内容的许可。新增或变更的包必须经过对确切内容的明确审查并保存;已接受但过期的内容会标示为最后已知正常版本;如果信任已撤销或字节损坏,则一律按拒绝处理。

报告漏洞

请使用 GitHub 的私密漏洞报告,或者如果你不愿意用 diff --git a/site/pages/zh-Hant/docs.html b/site/pages/zh-Hant/docs.html index 7bb0efa5..4c48dde7 100644 --- a/site/pages/zh-Hant/docs.html +++ b/site/pages/zh-Hant/docs.html @@ -352,6 +352,11 @@

規則

流量可供對照」 — 同一張空列表,意思正好相反,而其中只有一個說這條規則是安全的。畫面還會說出 它能看到的最早的流量:一條只對照了兩小時歷史的規則,就是只對照了兩小時歷史。

+

受管理的廣告與追蹤器套件

+

規則 › 套件包含三個官方主機名稱清單:廣告、跨網站追蹤和隱私權與遙測。它們從一開始便可見,且預設全部關閉。封鎖功能需要網路延伸功能;nettop 取樣器只能觀察。

+

僅限官方簽署的更新。 Flowlight 會檢查一個固定的 HTTPS 來源,以尋找由發佈者簽署的新版目錄。自動檢查預設開啟,可在此處關閉。簽章可驗證內容,但不會啟用內容:新套件會保持關閉;如果目的地有所變更,已啟用的套件將停止執行,直到你檢閱確切的新版本並按下儲存。服務中斷期間,已接受的內容可能會繼續保留為已知正常的最後版本,並明確顯示為已過時。這些是主機名稱層級的網路封鎖,無法移除 DOM 或版面配置中的空白、瀏覽器覆疊層、由指令碼產生的彈出式視窗或原生應用程式視窗。

+

當 HTTPS 檢查將 App 轉送經過 Flowlight 的代理伺服器時,一般目的地規則和已啟用的受管理套件會在開啟任何來源伺服器連線之前,使用原 App 的身分作出判定。對於使用憑證綁定或始終不解密的 CONNECT 通道,這項判定會按主機名稱進行;代理的允許清單仍只適用於直接經過網路延伸功能的路徑。如果擁有者或主機名稱未知,則會允許連線。

+

允許清單

在「AI 代理」中選擇一個代理,用它詳細資料中的「允許清單」指定它可以連到哪裡。輸入網域 (含子網域)、IP 位址或 CIDR 範圍,或挑一個預設組合:GitHub、npm、PyPI、Homebrew、Docker Hub、Rust crates、Go modules 或 @@ -370,8 +375,7 @@

拒絕連線

流量。一台被封死的 Mac 比一個被封住的代理更糟,所以有疑慮時,連線會被允許並記錄下來。 Flowlight 停止執行後幾秒,強制執行也會失效 — 沒人看得到的拒絕比不拒絕更糟,而解除它的 方法就在 App 裡。

-

HTTPS 檢查和封鎖不能並用:轉送經過 Flowlight 代理伺服器的流量,是以 Flowlight 自己的身分出去的, - 而那絕不會被拒絕。

+

對於轉送經過 HTTPS 檢查的 App,Flowlight 的代理伺服器會在開啟任何來源伺服器連線之前,使用原 App 的身分,對一般目的地規則和已啟用的受管理套件作出判定。對於使用憑證綁定或始終不解密的 CONNECT 通道,這項判定會按主機名稱進行。代理的允許清單仍只適用於直接經過網路延伸功能的路徑;如果擁有者或主機名稱未知,則會允許連線。

代理防護機制:封鎖一個工具、一個伺服器或一項資源

這個代理可以用哪些工具,和它可以連到哪些主機是兩個不同的問題,所以它有自己的 @@ -579,9 +583,7 @@

常見問答

它會顯示安裝了哪些延伸功能、macOS 是否真的啟動了 Flowlight 的過濾器,以及最近一次記錄是多久以前。 把結果貼到問題回報裡。
Flowlight 會把關於我的任何東西送到別處嗎?
-
沒有帳號、沒有遙測、也沒有分析,而且你的流量資料庫從不上傳。確實有三個請求會離開這台 Mac,它們都列在隱私頁面上: - 每天的更新檢查和選用的網路擁有者查詢,這兩個都有關閉開關;以及 Flowlight 在取樣器上執行時的反向 DNS,它沒有開關 — - 少了它,取樣器就沒辦法為大多數連線命名。
+
沒有帳號、沒有遙測、也沒有分析,而且你的流量資料庫從不上傳。隱私頁面列出每項要求:版本與簽署規則套件檢查,以及選用的網路擁有者查詢都有關閉開關;反向 DNS 只在取樣器模式執行且沒有開關,因為少了它,大多數連線將無法命名。
它會讓我的 Mac 變慢或耗電嗎?
視窗關著時,Flowlight 幾乎什麼都不做:只維持選單列上的速率,並每秒寫入一列資料。網路延伸功能 比備用的取樣器更省,後者每秒都會啟動一個短命的 nettop 程序。
diff --git a/site/pages/zh-Hant/index.html b/site/pages/zh-Hant/index.html index 6b373ea0..260edd55 100644 --- a/site/pages/zh-Hant/index.html +++ b/site/pages/zh-Hant/index.html @@ -166,6 +166,7 @@

看住 AI 代理怎麼用網路

它是對你本來就信任的軟體的管控,不是一道擋住你不信任的軟體的屏障 — 見威脅模型。

Cursor ⇥ raw.githubusercontent.com · 已拒絕 4 次 · 平日 18:00–09:00

+
⛔ 需自行開啟

受管理的廣告與追蹤器套件

三個官方套件均已內建,從一開始便可見,但預設全部關閉。Flowlight 會從一個固定的 HTTPS 來源檢查已簽署的更新;新增或變更的內容在你檢閱確切版本並按下儲存之前,不會進入強制執行。服務中斷期間,經過驗證且已接受的內容可能會以已知正常的最後版本保留,並明確標示為已過時。

這些套件依主機名稱封鎖,因此無法移除第一方廣告、DOM 或版面配置空白、瀏覽器覆疊層、由指令碼產生的彈出式視窗或原生 App 視窗。

⛔ 拒絕

為代理的工具設下防護機制

@@ -364,7 +365,7 @@

一個不用你盯著的監控工具

只留在你的 Mac 上

一個本機 SQLite 檔案。不用帳號、沒有雲端同步、沒有遙測。

預設只看後設資料

主機名稱、連接埠和位元組計數。解密 HTTPS 是另一個必須由你開啟的模式,而且一切都留在你的 Mac 上。

在核心層就先過濾

擷取只放行 DNS 回應和 TLS ClientHello。其他所有內容在 Flowlight 看到之前就被丟掉了。

-
查詢由你作主

要辨識某個位址屬於誰,只會把沒有主機名稱的公開 IP 傳給 Team Cymru,而且一鍵就能關閉。每天的更新檢查只是向 GitHub 查詢最新版本,同樣也可以關掉。

+
查詢由你作主

要辨識某個位址屬於誰,只會把沒有主機名稱的公開 IP 傳給 Team Cymru。版本與已簽署規則套件檢查會傳送不含流量或套件設定的標準 HTTPS 請求。兩者都有各自的關閉開關。

diff --git a/site/pages/zh-Hant/privacy.html b/site/pages/zh-Hant/privacy.html index 613291bd..351210bf 100644 --- a/site/pages/zh-Hant/privacy.html +++ b/site/pages/zh-Hant/privacy.html @@ -55,6 +55,7 @@

這個 App 發出的網路請求

請求送到哪裡內容是什麼什麼時候 更新檢查api.github.com一個查詢最新版本的標準 HTTPS 請求;GitHub 會看到你的 IP 位址和 Flowlight/<version> 這個 user agent啟動時以及每天一次,自 0.1.2 起。關閉開關在「設定」› 「一般」 更新下載github.com磁碟映像檔及其總和檢查碼檔案只有在你點「下載並開啟」時 + 官方規則套件更新flowlight.xinbetween.com向固定官方來源傳送的標準 HTTPS 要求。伺服器會看到你的 IP 位址、Flowlight/<version>使用者代理程式和條件式快取驗證器;不會收到任何流量、套件設定或裝置識別碼啟動後在檢查到期時執行,此後在運作正常的情況下每天最多執行一次。預設開啟;可在規則 › 套件中關閉或手動選擇立即檢查 反向 DNS你設定的 DNS 解析器在沒有看到主機名稱時,查詢某個 IP 位址登記的名稱需要時進行,在取樣器擷取模式下,也就是網路延伸功能開始運作之前的預設模式。沒有關閉開關:少了它,取樣器就沒辦法為大多數連線命名。網路延伸功能會從流量本身為它們命名,因此不做這件事 網路擁有者查詢Team Cymru,透過 DNS沒有已知主機名稱的公開 IP 位址。私有和保留位址絕不會離開你的 Mac預設開啟。關閉開關在「擷取」› 「辨識網路擁有者」 用託管模型提問你選擇的供應商:Anthropic、OpenAI、Google Gemini 或相容的端點你的問題,以及模型所選用的 Flowlight 唯讀查詢回傳的結果。流量資料庫本身不會被上傳只有在你選擇託管供應商並送出問題時。「提問」畫面會顯示送出的請求 diff --git a/site/pages/zh-Hant/threat-model.html b/site/pages/zh-Hant/threat-model.html index 8b8d9860..9e2cf9d1 100644 --- a/site/pages/zh-Hant/threat-model.html +++ b/site/pages/zh-Hant/threat-model.html @@ -80,31 +80,29 @@

檢查會漏掉什麼

逐個說明。

封鎖是什麼,不是什麼

-

一條拒絕連線的規則是由網路延伸功能執行的,就在連線的路徑上,在連線建立之前。一條指名 URL 的規則則由檢查用的代理伺服器 - 執行,它用 App 讀得到的狀態來回答那個請求。這兩處是 Flowlight 裡唯一動手、而不只是回報的部分 — nettop 取樣器什麼都拒絕不了, - 所以在用取樣器的 Mac 上,沒有任何規則會被強制執行,而 Flowlight 會把這一點說出來,而不是擺一個什麼都不做的開關。

+

一條拒絕連線的規則通常由網路延伸功能執行,就在連線的路徑上,在連線建立之前。對於轉送經過 HTTPS 檢查的流量,本機代理伺服器 + 會在開啟來源伺服器連線之前,針對原始用戶端套用目的地規則和已啟用的受管理套件;一條指名 URL 的規則也會在解密後由它執行, + 並用 App 讀得到的狀態作出回應。這兩處是 Flowlight 裡唯一動手、而不只是回報的部分 — nettop 取樣器什麼都拒絕不了,所以在用 + 取樣器的 Mac 上,沒有任何規則會被強制執行,而 Flowlight 會把這一點說出來,而不是擺一個什麼都不做的開關。

但它依然不是防護。一個還能執行 shell 的代理,可以自己動手做那個被拒絕的工具原本要做的事。一次你能一鍵解除的拒絕, 也是代理下一次嘗試就可能走過去的拒絕。本機流量、DNS、Apple 自己的服務,以及 Flowlight 自己的連線永遠不會被拒絕, 因為一台被封死的 Mac 比一個被封住的代理更糟。而且 Flowlight 停止執行後幾秒,強制執行就會失效: 沒人看得到的拒絕比不拒絕更糟。

-

檢查與拒絕無法並存。經由 Flowlight 自己的代理伺服器轉送的流量,是以 Flowlight 的流量的身分離開這台 Mac 的, - 而 Flowlight 從不拒絕自己 — 所以一個你正在讀它請求的 App,也就是一個規則再也攔不住它連線的 App。為了看得更多而開啟檢查, - 代價就是強制執行,而畫面上先前並沒有說這件事。

+

檢查仍能按主機名稱拒絕連線,但有一條界線。轉送經過 Flowlight 代理伺服器的流量,會以豁免的 Flowlight + 流量身分離開 Mac,因此代理伺服器會先使用原 App 的身分,對一般目的地規則和已啟用的受管理套件作出判定。即使通道使用憑證綁定 + 或始終不解密,也能這樣判定,因為 CONNECT 會指明主機。代理的允許清單仍由網路延伸功能判定,不適用於從代理伺服器到來源伺服器 + 的這一段路徑;沒有可信擁有者或主機名稱的流量會被允許,而不會在無人察覺的情況下遭到拒絕。

Flowlight 自己留著什麼

流量的後設資料,放在本機的 SQLite 資料庫裡,保留期限有公布。開啟 HTTPS 檢查後,它還會把請求與回應的內文留 3 天 — 那是它留下來的東西裡最敏感的。憑證類標頭會依名稱、也依其中的字詞遮蔽,那是一種推測規則,不是保證; 一個把祕密放在沒人認得的名稱底下的標頭,是可能被記錄下來的。

-

流量資料庫從不上傳。不過確實有三樣比較小的東西,會在你沒有要求的情況下離開這台 Mac,它們都在隱私頁面上 - 逐項列出:更新檢查,它在啟動時和每天一次向 GitHub 詢問最新的發布;網路擁有者查詢,它就沒有主機名稱的公開位址向 Team Cymru - 詢問;以及,當 Flowlight 在讀計數器而不是執行它的延伸功能時,對那些它沒看到名稱的位址所做的一次反向 DNS 查詢。前兩樣都有 - 關閉開關。第三樣沒有,因為在取樣器上,一條連線能有名稱,大半就靠它 — 這個查詢會走這台 Mac 本來就在用的解析器,而一旦 - 延伸功能接手、從流量本身為連線命名,它就停了。用託管模型「提問」送出去的東西,比它們任何一個都多得多,而它也是唯一在你 - 設定之前一直關著的。

+

流量資料庫從不上傳。隱私頁面列出每項外部要求:版本與簽署規則套件檢查、選用的網路擁有者查詢,以及取樣器的反向 DNS。前三項都有關閉開關;反向 DNS 沒有,因為少了它,取樣器無法為大多數連線命名。託管模型會傳送更多內容,並在設定前保持關閉。

更新

版本以 Developer ID 簽章並經過公證。更新程式要求有公布的總和檢查碼,沒有就拒絕下載;接著會在安裝之前確認下載到的 App 是否由與執行中副本相同的團隊簽章。由別人簽章的更新會被拒絕,即使它在其他方面都有效。

+

官方規則套件使用一套用途受限的獨立簽章和一個固定的 HTTPS 來源。App 會獨立保存最高序號水位,在啟動時重新驗證快取的確切位元組,拒絕回復舊版和相互矛盾的發佈,並且絕不會把發佈者簽章視為強制執行變更內容的許可。新增或變更的套件必須經過對確切內容的明確檢閱並儲存;已接受但過時的內容會標示為已知正常的最後版本;如果信任已撤銷或位元組損壞,則一律以拒絕處理。

回報漏洞

請使用 GitHub 的私密漏洞回報,或者如果你不想用