From b4014286adb23708dd7314299f2d9d8fbe927897 Mon Sep 17 00:00:00 2001 From: blessdyb Date: Fri, 9 Oct 2026 09:11:50 -0700 Subject: [PATCH 1/4] Add plugin package management Co-Authored-By: Claude Code --- Flowlight/Plugins/PluginEngine.swift | 4 +- Flowlight/Plugins/PluginModels.swift | 26 ++- Flowlight/Plugins/PluginStore.swift | 80 ++++++++++ Flowlight/Storage/TrafficDatabase.swift | 35 +++++ Flowlight/UI/PluginSettingsView.swift | 201 +++++++++++++++++++++--- FlowlightTests/PluginTests.swift | 55 +++++++ docs/llms-full.txt | 4 +- docs/plugins/index.html | 4 +- site/pages/plugins.html | 4 +- templates/inspection-plugin/README.md | 4 +- 10 files changed, 386 insertions(+), 31 deletions(-) diff --git a/Flowlight/Plugins/PluginEngine.swift b/Flowlight/Plugins/PluginEngine.swift index 688c9f4..5e7c53b 100644 --- a/Flowlight/Plugins/PluginEngine.swift +++ b/Flowlight/Plugins/PluginEngine.swift @@ -38,7 +38,7 @@ enum PluginEngine { let enabled = Dictionary(uniqueKeysWithValues: manifests.map { ($0.id, $0.enabled) }) let context = PluginContext(exchange: exchange) return builtInRules.flatMap { rule -> [PluginFindingDraft] in - guard enabled[rule.manifest.id] ?? rule.manifest.enabled else { return [] } + guard enabled[rule.manifest.id] == true else { return [] } return rule.evaluate(context) } } @@ -48,8 +48,10 @@ enum PluginEngine { for builtIn in builtInManifests { if var existing = byID[builtIn.id] { let enabled = existing.enabled + let configuration = existing.configuration existing = builtIn existing.enabled = enabled + existing.configuration = configuration byID[builtIn.id] = existing } else { byID[builtIn.id] = builtIn diff --git a/Flowlight/Plugins/PluginModels.swift b/Flowlight/Plugins/PluginModels.swift index 72fea8c..d528543 100644 --- a/Flowlight/Plugins/PluginModels.swift +++ b/Flowlight/Plugins/PluginModels.swift @@ -14,6 +14,7 @@ struct PluginManifest: Codable, Identifiable, Equatable, Sendable { var ruleVersion: Int = 1 var privacySummary: String var capabilities: [Capability] = [.annotate] + var configuration: [String: String] = [:] enum Kind: String, Codable, Sendable { case traffic, llmMCP } enum Source: String, Codable, Sendable { case builtIn, installed } @@ -38,7 +39,8 @@ struct PluginManifest: Codable, Identifiable, Equatable, Sendable { init(id: String, name: String, version: String, kind: Kind, enabled: Bool = true, source: Source = .builtIn, publisher: Publisher = .official, guardrailProvider: GuardrailProvider = .flowlight, description: String, - ruleVersion: Int = 1, privacySummary: String, capabilities: [Capability] = [.annotate]) { + ruleVersion: Int = 1, privacySummary: String, capabilities: [Capability] = [.annotate], + configuration: [String: String] = [:]) { self.id = id self.name = name self.version = version @@ -51,10 +53,11 @@ struct PluginManifest: Codable, Identifiable, Equatable, Sendable { self.ruleVersion = ruleVersion self.privacySummary = privacySummary self.capabilities = capabilities + self.configuration = configuration } private enum CodingKeys: String, CodingKey { - case id, name, version, kind, enabled, source, publisher, guardrailProvider, description, ruleVersion, privacySummary, capabilities + case id, name, version, kind, enabled, source, publisher, guardrailProvider, description, ruleVersion, privacySummary, capabilities, configuration } init(from decoder: Decoder) throws { @@ -71,9 +74,28 @@ struct PluginManifest: Codable, Identifiable, Equatable, Sendable { ruleVersion = try values.decodeIfPresent(Int.self, forKey: .ruleVersion) ?? 1 privacySummary = try values.decode(String.self, forKey: .privacySummary) capabilities = try values.decodeIfPresent([Capability].self, forKey: .capabilities) ?? [.annotate] + configuration = try values.decodeIfPresent([String: String].self, forKey: .configuration) ?? [:] } } +struct PluginPackage: Codable, Equatable, Sendable { + var manifest: PluginManifest + + init(manifest: PluginManifest) { + self.manifest = manifest + } + + init(from decoder: Decoder) throws { + if let package = try? decoder.container(keyedBy: CodingKeys.self), package.contains(.manifest) { + manifest = try package.decode(PluginManifest.self, forKey: .manifest) + } else { + manifest = try PluginManifest(from: decoder) + } + } + + private enum CodingKeys: String, CodingKey { case manifest } +} + struct PluginEvidence: Codable, Equatable, Sendable { var label: String var value: String diff --git a/Flowlight/Plugins/PluginStore.swift b/Flowlight/Plugins/PluginStore.swift index 94b69aa..68f0b8b 100644 --- a/Flowlight/Plugins/PluginStore.swift +++ b/Flowlight/Plugins/PluginStore.swift @@ -1,5 +1,27 @@ import Foundation +struct PluginImportResult: Sendable { + var manifest: PluginManifest + var installed: Bool +} + +struct PluginBackfillResult: Sendable { + var pluginID: String? + var findingCount: Int +} + +enum PluginPackageError: LocalizedError, Equatable { + case reservedBuiltInID + case invalidID + + var errorDescription: String? { + switch self { + case .reservedBuiltInID: return L("Installed plugin IDs cannot reuse a built-in plugin ID.") + case .invalidID: return L("Plugin IDs must contain only letters, numbers, dots, underscores, and hyphens.") + } + } +} + @MainActor final class PluginStore: ObservableObject { @Published private(set) var manifests: [PluginManifest] = [] @@ -36,6 +58,64 @@ final class PluginStore: ObservableObject { version += 1 } + func updateConfiguration(_ manifest: PluginManifest, configuration: [String: String]) { + guard let index = manifests.firstIndex(where: { $0.id == manifest.id }) else { return } + manifests[index].configuration = configuration + db?.async { try $0.updatePluginConfiguration(id: manifest.id, configuration: configuration) } + version += 1 + } + + func importPackage(data: Data, completion: @escaping @MainActor (Result) -> Void) { + guard let db else { return } + db.async { [weak self] db in + do { + var manifest = try JSONDecoder().decode(PluginPackage.self, from: data).manifest + guard manifest.id.range(of: #"^[A-Za-z0-9_.-]+$"#, options: .regularExpression) != nil else { throw PluginPackageError.invalidID } + guard !PluginEngine.builtInManifests.contains(where: { $0.id == manifest.id }) else { throw PluginPackageError.reservedBuiltInID } + manifest.source = .installed + if manifest.publisher != .official { manifest.publisher = .thirdParty } + if manifest.capabilities.isEmpty { manifest.capabilities = [.annotate] } + try db.savePluginManifest(manifest) + let merged = PluginEngine.mergedManifests(try db.loadPluginManifests()) + Task { @MainActor in + self?.manifests = merged + self?.version += 1 + completion(.success(PluginImportResult(manifest: manifest, installed: true))) + } + } catch { + Task { @MainActor in completion(.failure(error)) } + } + } + } + + func exportPackage(_ manifest: PluginManifest) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(PluginPackage(manifest: manifest)) + } + + func removeInstalled(_ manifest: PluginManifest) { + guard manifest.source == .installed else { return } + manifests.removeAll { $0.id == manifest.id } + db?.async { try $0.deleteInstalledPlugin(id: manifest.id) } + version += 1 + } + + func backfill(_ manifest: PluginManifest? = nil, completion: @escaping @MainActor (Result) -> Void) { + guard let db else { return } + db.async { [weak self] db in + do { + let count = try db.backfillPluginFindings(pluginID: manifest?.id) + Task { @MainActor in + self?.version += 1 + completion(.success(PluginBackfillResult(pluginID: manifest?.id, findingCount: count))) + } + } catch { + Task { @MainActor in completion(.failure(error)) } + } + } + } + func clearFindings() { db?.async { [weak self] db in try db.clearPluginFindings() diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift index 9cdd715..5c9c60e 100644 --- a/Flowlight/Storage/TrafficDatabase.swift +++ b/Flowlight/Storage/TrafficDatabase.swift @@ -1025,6 +1025,41 @@ final class TrafficDatabase: @unchecked Sendable { } } + func updatePluginConfiguration(id: String, configuration: [String: String]) throws { + guard var manifest = try loadPluginManifests().first(where: { $0.id == id }) else { return } + manifest.configuration = configuration + try savePluginManifest(manifest) + } + + func deleteInstalledPlugin(id: String) throws { + guard let manifest = try loadPluginManifests().first(where: { $0.id == id }), manifest.source == .installed else { return } + try conn.transaction { + try conn.run("DELETE FROM plugin_findings WHERE plugin_id = ?", [.text(id)]) + try conn.run("DELETE FROM plugins WHERE id = ?", [.text(id)]) + } + } + + @discardableResult + func backfillPluginFindings(pluginID: String? = nil, since: Date = Date().addingTimeInterval(-14 * 86400), limit: Int = 500) throws -> Int { + let manifests = PluginEngine.mergedManifests(try loadPluginManifests()) + let active = pluginID.map { id in manifests.filter { $0.id == id } } ?? manifests + let builtInIDs = Set(PluginEngine.builtInManifests.map(\.id)) + let evaluable = active.filter { builtInIDs.contains($0.id) } + guard !evaluable.isEmpty else { return 0 } + let exchanges = try exchanges(since: since, limit: limit) + var inserted = 0 + try conn.transaction { + if let pluginID { try conn.run("DELETE FROM plugin_findings WHERE plugin_id = ?", [.text(pluginID)]) } + else { try conn.run("DELETE FROM plugin_findings WHERE plugin_id IN (\(Array(repeating: "?", count: builtInIDs.count).joined(separator: ",")))", builtInIDs.map(SQLValue.text)) } + for exchange in exchanges { + let before = conn.changes + try insertPluginFindings(PluginEngine.evaluate(exchange, manifests: evaluable), forExchangeID: exchange.id ?? 0) + inserted += max(0, conn.changes - before) + } + } + return inserted + } + func insertPluginFindings(_ findings: [PluginFindingDraft], forExchangeID exchangeID: Int64) throws { guard !findings.isEmpty else { return } let encoder = JSONEncoder() diff --git a/Flowlight/UI/PluginSettingsView.swift b/Flowlight/UI/PluginSettingsView.swift index bd4c738..f9de169 100644 --- a/Flowlight/UI/PluginSettingsView.swift +++ b/Flowlight/UI/PluginSettingsView.swift @@ -1,31 +1,54 @@ +import AppKit import SwiftUI +import UniformTypeIdentifiers struct PluginSettingsView: View { @EnvironmentObject var monitor: TrafficMonitor @State private var showClearConfirmation = false + @State private var message: String? + @State private var configuring: PluginManifest? + @State private var removing: PluginManifest? var body: some View { Form { Section { Text(L("Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves.")) .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + HStack(spacing: 8) { + Button(L("Import Package…"), action: importPackage) + Button(L("Backfill Findings")) { backfill(nil) } + Button(L("Clear plugin findings…"), role: .destructive) { showClearConfirmation = true } + } + if let message { + Text(message).font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + } footer: { + Text(L("Import reads a local plugin manifest package and stores it as installed metadata. Until sandboxed plugins ship, installed packages are visible and configurable but only Flowlight built-ins create findings.")) + .font(.caption).foregroundStyle(.secondary) } pluginSection(L("Official"), plugins: monitor.plugins.officialPlugins, empty: L("No official plugins are installed.")) pluginSection(L("Third-party"), plugins: monitor.plugins.thirdPartyPlugins, - empty: L("Third-party plugins are not installed in this version.")) - Section { - Button(L("Clear plugin findings…"), role: .destructive) { showClearConfirmation = true } - .help(L("Delete retained plugin annotations without deleting recorded requests or plugin settings")) - } + empty: L("No third-party plugin packages are installed.")) } .formStyle(.grouped) - .frame(height: 520) + .frame(height: 620) .confirmationDialog(L("Clear plugin findings?"), isPresented: $showClearConfirmation, titleVisibility: .visible) { Button(L("Clear findings"), role: .destructive) { monitor.clearPluginFindings() } } message: { Text(L("Recorded requests, response bodies, rules, guardrails, and plugin enable settings stay in place.")) } + .confirmationDialog(L("Remove installed plugin?"), item: $removing, titleVisibility: .visible) { plugin in + Button(L("Remove %@", plugin.name), role: .destructive) { monitor.plugins.removeInstalled(plugin) } + } message: { plugin in + Text(L("%@ and its retained findings will be removed. Built-in plugins cannot be removed.", plugin.name)) + } + .sheet(item: $configuring) { plugin in + PluginConfigurationSheet(plugin: plugin) { configuration in + monitor.plugins.updateConfiguration(plugin, configuration: configuration) + message = L("Saved configuration for %@.", plugin.name) + } + } } private func pluginSection(_ title: String, plugins: [PluginManifest], empty: String) -> some View { @@ -34,35 +57,164 @@ struct PluginSettingsView: View { Text(empty).font(.caption).foregroundStyle(.secondary) } else { ForEach(plugins) { plugin in - PluginSettingsRow(plugin: plugin) { enabled in + PluginSettingsRow(plugin: plugin, setEnabled: { enabled in monitor.plugins.setEnabled(plugin, enabled) - } + }, configure: { + configuring = plugin + }, exportPackage: { + exportPackage(plugin) + }, backfill: { + backfill(plugin) + }, remove: plugin.source == .installed ? { + removing = plugin + } : nil) } } } } + + private func importPackage() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.json] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.prompt = L("Import") + panel.message = L("Choose a Flowlight inspection plugin manifest or package JSON file.") + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + let data = try Data(contentsOf: url) + monitor.plugins.importPackage(data: data) { result in + switch result { + case .success(let imported): message = L("Installed %@.", imported.manifest.name) + case .failure(let error): message = L("Import failed: %@", error.localizedDescription) + } + } + } catch { + message = L("Import failed: %@", error.localizedDescription) + } + } + + private func exportPackage(_ plugin: PluginManifest) { + do { + let data = try monitor.plugins.exportPackage(plugin) + let panel = NSSavePanel() + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = "\(plugin.id)-plugin.json" + if panel.runModal() == .OK, let url = panel.url { + try data.write(to: url, options: .atomic) + message = L("Exported %@.", plugin.name) + } + } catch { + message = L("Export failed: %@", error.localizedDescription) + } + } + + private func backfill(_ plugin: PluginManifest?) { + monitor.plugins.backfill(plugin) { result in + switch result { + case .success(let result): + message = plugin.map { L("Backfilled %@: %lld findings.", $0.name, result.findingCount) } + ?? L("Backfilled built-in plugins: %lld findings.", result.findingCount) + case .failure(let error): message = L("Backfill failed: %@", error.localizedDescription) + } + } + } } private struct PluginSettingsRow: View { var plugin: PluginManifest var setEnabled: (Bool) -> Void + var configure: () -> Void + var exportPackage: () -> Void + var backfill: () -> Void + var remove: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: Binding(get: { plugin.enabled }, set: setEnabled)) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(plugin.name).font(.callout.bold()) + PluginPill(plugin.kind.title) + PluginPill(plugin.guardrailProvider.title) + PluginPill(plugin.source.title) + if plugin.capabilities.contains(.suggestGuardrail) { PluginPill(L("Suggests guardrails")) } + } + Text(plugin.description).font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + Text(plugin.privacySummary).font(.caption2).foregroundStyle(.tertiary).fixedSize(horizontal: false, vertical: true) + Text(L("%@ · v%@ · rule v%lld", plugin.publisher.title, plugin.version, plugin.ruleVersion)) + .font(.caption2).foregroundStyle(.tertiary) + if !plugin.configuration.isEmpty { + Text(L("Configured: %@", plugin.configuration.keys.sorted().joined(separator: ", "))) + .font(.caption2).foregroundStyle(.tertiary) + } + } + } + .toggleStyle(.switch) + HStack(spacing: 8) { + Button(L("Configure…"), action: configure) + Button(L("Export…"), action: exportPackage) + Button(L("Backfill"), action: backfill) + if let remove { Button(L("Remove"), role: .destructive, action: remove) } + } + .font(.caption) + } + .padding(.vertical, 2) + } +} + +private struct PluginConfigurationSheet: View { + @Environment(\.dismiss) private var dismiss + var plugin: PluginManifest + var save: ([String: String]) -> Void + @State private var rows: [ConfigurationRow] + + init(plugin: PluginManifest, save: @escaping ([String: String]) -> Void) { + self.plugin = plugin + self.save = save + _rows = State(initialValue: plugin.configuration.sorted { $0.key < $1.key }.map { ConfigurationRow(key: $0.key, value: $0.value) }) + } var body: some View { - Toggle(isOn: Binding(get: { plugin.enabled }, set: setEnabled)) { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Text(plugin.name).font(.callout.bold()) - PluginPill(plugin.kind.title) - PluginPill(plugin.guardrailProvider.title) - if plugin.capabilities.contains(.suggestGuardrail) { PluginPill(L("Suggests guardrails")) } + VStack(alignment: .leading, spacing: 12) { + Text(L("Configure %@", plugin.name)).font(.headline) + Text(L("Configuration is stored locally as string key/value metadata. It does not grant a plugin network, file, or traffic mutation access.")) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + ScrollView { + VStack(spacing: 8) { + ForEach($rows) { $row in + HStack(spacing: 8) { + TextField(L("Key"), text: $row.key).textFieldStyle(.roundedBorder) + TextField(L("Value"), text: $row.value).textFieldStyle(.roundedBorder) + Button { rows.removeAll { $0.id == row.id } } label: { Image(systemName: "minus.circle") } + .buttonStyle(.borderless).help(L("Remove this setting")) + } + } + } + } + HStack { + Button(L("Add Setting")) { rows.append(ConfigurationRow()) } + Spacer() + Button(L("Cancel")) { dismiss() } + Button(L("Save")) { + save(Dictionary(uniqueKeysWithValues: rows.compactMap { row in + let key = row.key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return nil } + return (key, row.value) + })) + dismiss() } - Text(plugin.description).font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) - Text(plugin.privacySummary).font(.caption2).foregroundStyle(.tertiary).fixedSize(horizontal: false, vertical: true) - Text(L("%@ · v%@ · rule v%lld", plugin.publisher.title, plugin.version, plugin.ruleVersion)) - .font(.caption2).foregroundStyle(.tertiary) + .keyboardShortcut(.defaultAction) } } - .toggleStyle(.switch) + .padding(20) + .frame(width: 520, height: 360) + } + + private struct ConfigurationRow: Identifiable { + let id = UUID() + var key = "" + var value = "" } } @@ -86,3 +238,12 @@ private extension PluginManifest.Publisher { } } } + +private extension PluginManifest.Source { + var title: String { + switch self { + case .builtIn: return L("Built-in") + case .installed: return L("Installed") + } + } +} diff --git a/FlowlightTests/PluginTests.swift b/FlowlightTests/PluginTests.swift index 3836964..851f1ef 100644 --- a/FlowlightTests/PluginTests.swift +++ b/FlowlightTests/PluginTests.swift @@ -82,10 +82,65 @@ final class PluginTests: XCTestCase { description: "Detects and masks PII.", privacySummary: "Uses redacted inspection metadata.") manifest.source = .installed manifest.publisher = .official + manifest.configuration = ["policy": "default"] let data = try JSONEncoder().encode(manifest) let decoded = try JSONDecoder().decode(PluginManifest.self, from: data) XCTAssertEqual(decoded.publisher, .official) XCTAssertEqual(decoded.guardrailProvider, .presidio) + XCTAssertEqual(decoded.configuration["policy"], "default") XCTAssertEqual(PluginManifest.GuardrailProvider.prismaAIRS.title, "PANW Prisma AIRS") } + + func testPluginPackageAcceptsBareManifestAndWrappedPackage() throws { + let bare = Data(#"{"id":"thirdparty.test","name":"Test","version":"0.1.0","kind":"traffic","source":"installed","publisher":"thirdParty","guardrailProvider":"custom","description":"Test plugin.","privacySummary":"Uses metadata only.","capabilities":["annotate"]}"#.utf8) + let decodedBare = try JSONDecoder().decode(PluginPackage.self, from: bare) + XCTAssertEqual(decodedBare.manifest.id, "thirdparty.test") + + let wrapped = try JSONEncoder().encode(PluginPackage(manifest: decodedBare.manifest)) + let decodedWrapped = try JSONDecoder().decode(PluginPackage.self, from: wrapped) + XCTAssertEqual(decodedWrapped.manifest.id, "thirdparty.test") + } + + @MainActor + func testPluginStoreRejectsReservedBuiltInPackageID() { + let store = PluginStore() + let data = Data(#"{"id":"traffic.large-upload","name":"Pretend","version":"0.1.0","kind":"traffic","source":"installed","publisher":"thirdParty","guardrailProvider":"custom","description":"Pretend plugin.","privacySummary":"Uses metadata only.","capabilities":["annotate"]}"#.utf8) + let exp = expectation(description: "import completes") + store.attach(db: try! database()) + store.importPackage(data: data) { result in + if case .failure(let error as PluginPackageError) = result { + XCTAssertEqual(error, .reservedBuiltInID) + } else { + XCTFail("expected reserved built-in ID rejection") + } + exp.fulfill() + } + wait(for: [exp], timeout: 2) + } + + func testInstalledPluginConfigurationAndRemovalPersist() throws { + let db = try database() + var manifest = PluginManifest(id: "thirdparty.example", name: "Example", version: "0.1.0", kind: .traffic, + source: .installed, publisher: .thirdParty, guardrailProvider: .custom, + description: "Example plugin.", privacySummary: "Uses metadata only.") + try db.savePluginManifest(manifest) + try db.updatePluginConfiguration(id: manifest.id, configuration: ["mode": "audit"]) + manifest = try XCTUnwrap(db.loadPluginManifests().first { $0.id == "thirdparty.example" }) + XCTAssertEqual(manifest.configuration["mode"], "audit") + + try db.deleteInstalledPlugin(id: manifest.id) + XCTAssertNil(try db.loadPluginManifests().first { $0.id == "thirdparty.example" }) + } + + func testBackfillRecreatesBuiltInFindingsForRetainedExchanges() throws { + let db = try database() + let id = try db.insertExchange(exchange()) + XCTAssertFalse(try db.pluginFindings(exchangeIDs: [id]).isEmpty) + try db.clearPluginFindings() + XCTAssertTrue(try db.pluginFindings(exchangeIDs: [id], visibleOnly: false).isEmpty) + let count = try db.backfillPluginFindings(pluginID: "traffic.large-upload") + XCTAssertEqual(count, 1) + let findings = try db.pluginFindings(exchangeIDs: [id], visibleOnly: false) + XCTAssertEqual(findings[id]?.map(\.pluginID), ["traffic.large-upload"]) + } } diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 2755563..e028895 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -1323,7 +1323,7 @@ Plugins Flowlight plugins are scoped to HTTPS inspection. They run after a request and response have already been captured, redacted, clipped, attributed to an app or agent, and written to the local database. A plugin can add findings and suggest ordinary Flowlight guardrails, but it does not block, rewrite, mock, or transform traffic by itself. - The first shipped plugins are official built-ins. The template package below is for designing third-party or official add-on packs against the manifest and finding format while the installed plugin loader is still being hardened. + Flowlight can import and export local plugin manifest packages from Settings. Installed packages are visible and configurable today; execution remains limited to official built-in rules until sandboxed advisory plugins ship. Manifest @@ -1396,7 +1396,7 @@ Plugins Template package - The starter package lives at templates/inspection-plugin. It includes a manifest, sample findings, a policy checklist and fixtures for ordinary traffic and LLM/MCP guardrail plugins. + The starter package lives at templates/inspection-plugin. It includes a manifest, sample findings, a policy checklist and fixtures for ordinary traffic and LLM/MCP guardrail plugins. Import the manifest JSON from Settings › Plugins, then export it again to share the stored package shape. cp -R templates/inspection-plugin my-flowlight-plugin cd my-flowlight-plugin diff --git a/docs/plugins/index.html b/docs/plugins/index.html index a0a9f17..43401ad 100644 --- a/docs/plugins/index.html +++ b/docs/plugins/index.html @@ -75,7 +75,7 @@

Write inspection plugins

The trust boundary

Flowlight plugins are scoped to HTTPS inspection. They run after a request and response have already been captured, redacted, clipped, attributed to an app or agent, and written to the local database. A plugin can add findings and suggest ordinary Flowlight guardrails, but it does not block, rewrite, mock, or transform traffic by itself.

-

The first shipped plugins are official built-ins. The template package below is for designing third-party or official add-on packs against the manifest and finding format while the installed plugin loader is still being hardened.

+

Flowlight can import and export local plugin manifest packages from Settings. Installed packages are visible and configurable today; execution remains limited to official built-in rules until sandboxed advisory plugins ship.

Manifest

A manifest tells Flowlight what the plugin is, who publishes it, what kind of inspected data it uses, and whether it can suggest guardrails.

@@ -133,7 +133,7 @@

Supported guardrail providers

Template package

-

The starter package lives at templates/inspection-plugin. It includes a manifest, sample findings, a policy checklist and fixtures for ordinary traffic and LLM/MCP guardrail plugins.

+

The starter package lives at templates/inspection-plugin. It includes a manifest, sample findings, a policy checklist and fixtures for ordinary traffic and LLM/MCP guardrail plugins. Import the manifest JSON from Settings › Plugins, then export it again to share the stored package shape.

cp -R templates/inspection-plugin my-flowlight-plugin
 cd my-flowlight-plugin
 python3 -m json.tool manifest.json
diff --git a/site/pages/plugins.html b/site/pages/plugins.html
index d67e6a6..55dbfb2 100644
--- a/site/pages/plugins.html
+++ b/site/pages/plugins.html
@@ -27,7 +27,7 @@ 

Write inspection plugins

The trust boundary

Flowlight plugins are scoped to HTTPS inspection. They run after a request and response have already been captured, redacted, clipped, attributed to an app or agent, and written to the local database. A plugin can add findings and suggest ordinary Flowlight guardrails, but it does not block, rewrite, mock, or transform traffic by itself.

-

The first shipped plugins are official built-ins. The template package below is for designing third-party or official add-on packs against the manifest and finding format while the installed plugin loader is still being hardened.

+

Flowlight can import and export local plugin manifest packages from Settings. Installed packages are visible and configurable today; execution remains limited to official built-in rules until sandboxed advisory plugins ship.

Manifest

A manifest tells Flowlight what the plugin is, who publishes it, what kind of inspected data it uses, and whether it can suggest guardrails.

@@ -85,7 +85,7 @@

Supported guardrail providers

Template package

-

The starter package lives at templates/inspection-plugin. It includes a manifest, sample findings, a policy checklist and fixtures for ordinary traffic and LLM/MCP guardrail plugins.

+

The starter package lives at templates/inspection-plugin. It includes a manifest, sample findings, a policy checklist and fixtures for ordinary traffic and LLM/MCP guardrail plugins. Import the manifest JSON from Settings › Plugins, then export it again to share the stored package shape.

cp -R templates/inspection-plugin my-flowlight-plugin
 cd my-flowlight-plugin
 python3 -m json.tool manifest.json
diff --git a/templates/inspection-plugin/README.md b/templates/inspection-plugin/README.md
index ea7d109..500187c 100644
--- a/templates/inspection-plugin/README.md
+++ b/templates/inspection-plugin/README.md
@@ -1,6 +1,6 @@
 # Flowlight Inspection Plugin Template
 
-This template describes an advisory Flowlight inspection plugin package. The first app release with plugins ships official built-in Swift plugins only; this package is the authoring shape for official add-on packs and future installed third-party plugins.
+This template describes an advisory Flowlight inspection plugin package. Flowlight can import and export this manifest shape today; installed packages are visible and configurable, while executable plugin logic remains limited to official built-in Swift rules until the sandbox phase.
 
 ## Trust boundary
 
@@ -10,7 +10,7 @@ Evidence must be bounded and redacted. Do not include raw request or response bo
 
 ## Files
 
-- `manifest.json`: plugin identity, publisher category, guardrail provider, privacy summary and capabilities.
+- `manifest.json`: plugin identity, publisher category, guardrail provider, privacy summary, optional `configuration` string map and capabilities.
 - `findings.example.json`: sample findings the plugin may produce.
 - `validate.py`: local schema and privacy sanity checks for the template package.
 

From 4f9b9ca30e5919a798cca6bb5bca2f201fed5459 Mon Sep 17 00:00:00 2001
From: blessdyb 
Date: Fri, 9 Oct 2026 09:13:34 -0700
Subject: [PATCH 2/4] Release Flowlight 0.14.1

Co-Authored-By: Claude Code 
---
 docs/404.html                        |  2 +-
 docs/about/index.html                |  2 +-
 docs/de/about/index.html             |  2 +-
 docs/de/docs/index.html              |  4 ++--
 docs/de/index.html                   |  6 +++---
 docs/de/privacy/index.html           |  2 +-
 docs/de/threat-model/index.html      |  2 +-
 docs/docs/index.html                 |  4 ++--
 docs/es/about/index.html             |  2 +-
 docs/es/docs/index.html              |  4 ++--
 docs/es/index.html                   |  6 +++---
 docs/es/privacy/index.html           |  2 +-
 docs/es/threat-model/index.html      |  2 +-
 docs/fr/about/index.html             |  2 +-
 docs/fr/docs/index.html              |  4 ++--
 docs/fr/index.html                   |  6 +++---
 docs/fr/privacy/index.html           |  2 +-
 docs/fr/threat-model/index.html      |  2 +-
 docs/index.html                      |  6 +++---
 docs/it/about/index.html             |  2 +-
 docs/it/docs/index.html              |  4 ++--
 docs/it/index.html                   |  6 +++---
 docs/it/privacy/index.html           |  2 +-
 docs/it/threat-model/index.html      |  2 +-
 docs/ja/about/index.html             |  2 +-
 docs/ja/docs/index.html              |  4 ++--
 docs/ja/index.html                   |  6 +++---
 docs/ja/privacy/index.html           |  2 +-
 docs/ja/threat-model/index.html      |  2 +-
 docs/ko/about/index.html             |  2 +-
 docs/ko/docs/index.html              |  4 ++--
 docs/ko/index.html                   |  6 +++---
 docs/ko/privacy/index.html           |  2 +-
 docs/ko/threat-model/index.html      |  2 +-
 docs/llms-full.txt                   | 20 +++++++++++++++++---
 docs/llms.txt                        |  2 +-
 docs/plugins/index.html              |  2 +-
 docs/privacy/index.html              |  2 +-
 docs/pt-PT/about/index.html          |  2 +-
 docs/pt-PT/docs/index.html           |  4 ++--
 docs/pt-PT/index.html                |  6 +++---
 docs/pt-PT/privacy/index.html        |  2 +-
 docs/pt-PT/threat-model/index.html   |  2 +-
 docs/releases/index.html             | 18 ++++++++++++++++--
 docs/threat-model/index.html         |  2 +-
 docs/zh-Hans/about/index.html        |  2 +-
 docs/zh-Hans/docs/index.html         |  4 ++--
 docs/zh-Hans/index.html              |  6 +++---
 docs/zh-Hans/privacy/index.html      |  2 +-
 docs/zh-Hans/threat-model/index.html |  2 +-
 docs/zh-Hant/about/index.html        |  2 +-
 docs/zh-Hant/docs/index.html         |  4 ++--
 docs/zh-Hant/index.html              |  6 +++---
 docs/zh-Hant/privacy/index.html      |  2 +-
 docs/zh-Hant/threat-model/index.html |  2 +-
 project.yml                          |  4 ++--
 site/pages/releases.html             | 16 +++++++++++++++-
 57 files changed, 133 insertions(+), 91 deletions(-)

diff --git a/docs/404.html b/docs/404.html
index f4747c3..35dfc09 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/about/index.html b/docs/about/index.html index 27945bd..42875ad 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 e57b0d0..8491efa 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 2efdc76..34ae545 100644 --- a/docs/de/docs/index.html +++ b/docs/de/docs/index.html @@ -76,7 +76,7 @@

Dokumentation

Flowlight benutzen

-

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

+

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

diff --git a/docs/de/index.html b/docs/de/index.html index 78625a4..df647c1 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.14.0macOS 15+UniversalGPL-3.0Keine Telemetrie

+

v0.14.1macOS 15+UniversalGPL-3.0Keine Telemetrie

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

Wisse, was deinen Mac verlässt.

diff --git a/docs/de/privacy/index.html b/docs/de/privacy/index.html index 1f86803..7f1b160 100644 --- a/docs/de/privacy/index.html +++ b/docs/de/privacy/index.html @@ -188,7 +188,7 @@

Kontakt

diff --git a/docs/de/threat-model/index.html b/docs/de/threat-model/index.html index e821bfa..464cf55 100644 --- a/docs/de/threat-model/index.html +++ b/docs/de/threat-model/index.html @@ -228,7 +228,7 @@

Eine Schwachstelle melden

diff --git a/docs/docs/index.html b/docs/docs/index.html index 737bb9a..0906bc4 100644 --- a/docs/docs/index.html +++ b/docs/docs/index.html @@ -76,7 +76,7 @@

Documentation

Using Flowlight

-

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

+

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

diff --git a/docs/es/about/index.html b/docs/es/about/index.html index 847fa3b..ec16ceb 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 07e3a0d..281effa 100644 --- a/docs/es/docs/index.html +++ b/docs/es/docs/index.html @@ -76,7 +76,7 @@

Documentación

Usar Flowlight

-

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

+

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

diff --git a/docs/es/index.html b/docs/es/index.html index c6dde3c..3d5a7d3 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.14.0macOS 15+UniversalGPL-3.0Sin telemetría

+

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

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

Ten claro qué sale de tu Mac.

diff --git a/docs/es/privacy/index.html b/docs/es/privacy/index.html index 6047cca..a7311c0 100644 --- a/docs/es/privacy/index.html +++ b/docs/es/privacy/index.html @@ -188,7 +188,7 @@

Contacto

diff --git a/docs/es/threat-model/index.html b/docs/es/threat-model/index.html index 8f3a2d7..d79ecbd 100644 --- a/docs/es/threat-model/index.html +++ b/docs/es/threat-model/index.html @@ -223,7 +223,7 @@

Informar de una vulnerabilidad

diff --git a/docs/fr/about/index.html b/docs/fr/about/index.html index 5c67fd0..5135dec 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 3b8ad42..afd42d8 100644 --- a/docs/fr/docs/index.html +++ b/docs/fr/docs/index.html @@ -76,7 +76,7 @@

Documentation

Utiliser Flowlight

-

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

+

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

diff --git a/docs/fr/index.html b/docs/fr/index.html index f46c68d..73e409a 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.14.0macOS 15+UniverselGPL-3.0Sans télémétrie

+

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

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

Sachez ce qui quitte votre Mac.

diff --git a/docs/fr/privacy/index.html b/docs/fr/privacy/index.html index d8d2144..1cf8af5 100644 --- a/docs/fr/privacy/index.html +++ b/docs/fr/privacy/index.html @@ -188,7 +188,7 @@

Contact

diff --git a/docs/fr/threat-model/index.html b/docs/fr/threat-model/index.html index d5ee24c..be339c2 100644 --- a/docs/fr/threat-model/index.html +++ b/docs/fr/threat-model/index.html @@ -226,7 +226,7 @@

Signaler une vulnérabilité

diff --git a/docs/index.html b/docs/index.html index e986f7a..77ac820 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.14.0macOS 15+UniversalGPL-3.0No telemetry

+

v0.14.1macOS 15+UniversalGPL-3.0No telemetry

@@ -580,7 +580,7 @@

Know what leaves your Mac.

diff --git a/docs/it/about/index.html b/docs/it/about/index.html index e762e6e..f7f490a 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 4908ce5..b85d191 100644 --- a/docs/it/docs/index.html +++ b/docs/it/docs/index.html @@ -76,7 +76,7 @@

Documentazione

Usare Flowlight

-

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

+

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

diff --git a/docs/it/index.html b/docs/it/index.html index dd96b8c..5db92f9 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.14.0macOS 15+UniversaleGPL-3.0Nessuna telemetria

+

v0.14.1macOS 15+UniversaleGPL-3.0Nessuna telemetria

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

Sappi che cosa esce dal tuo Mac.

diff --git a/docs/it/privacy/index.html b/docs/it/privacy/index.html index 8e4253f..4f9385c 100644 --- a/docs/it/privacy/index.html +++ b/docs/it/privacy/index.html @@ -188,7 +188,7 @@

Contatti

diff --git a/docs/it/threat-model/index.html b/docs/it/threat-model/index.html index 397244e..a574120 100644 --- a/docs/it/threat-model/index.html +++ b/docs/it/threat-model/index.html @@ -224,7 +224,7 @@

Segnalare una vulnerabilità

diff --git a/docs/ja/about/index.html b/docs/ja/about/index.html index 5e4743d..14277e1 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 e1b9711..b429130 100644 --- a/docs/ja/docs/index.html +++ b/docs/ja/docs/index.html @@ -76,7 +76,7 @@

ドキュメント

Flowlight の使い方

-

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

+

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

diff --git a/docs/ja/index.html b/docs/ja/index.html index 4f6644a..1e5125b 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.14.0macOS 15+ユニバーサルGPL-3.0テレメトリなし

+

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

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

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

diff --git a/docs/ja/privacy/index.html b/docs/ja/privacy/index.html index 539e818..5651eca 100644 --- a/docs/ja/privacy/index.html +++ b/docs/ja/privacy/index.html @@ -186,7 +186,7 @@

連絡先

diff --git a/docs/ja/threat-model/index.html b/docs/ja/threat-model/index.html index f574417..aab950c 100644 --- a/docs/ja/threat-model/index.html +++ b/docs/ja/threat-model/index.html @@ -225,7 +225,7 @@

脆弱性を報告する

diff --git a/docs/ko/about/index.html b/docs/ko/about/index.html index 3637c4b..8dc9de4 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 7d3adec..a024dea 100644 --- a/docs/ko/docs/index.html +++ b/docs/ko/docs/index.html @@ -76,7 +76,7 @@

문서

Flowlight 사용하기

-

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

+

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

diff --git a/docs/ko/index.html b/docs/ko/index.html index c86e5fa..fae241d 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.14.0macOS 15+유니버설GPL-3.0텔레메트리 없음

+

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

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

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

diff --git a/docs/ko/privacy/index.html b/docs/ko/privacy/index.html index 7c84b4b..13be8f2 100644 --- a/docs/ko/privacy/index.html +++ b/docs/ko/privacy/index.html @@ -189,7 +189,7 @@

문의

diff --git a/docs/ko/threat-model/index.html b/docs/ko/threat-model/index.html index e751faa..741e465 100644 --- a/docs/ko/threat-model/index.html +++ b/docs/ko/threat-model/index.html @@ -223,7 +223,7 @@

취약점 신고하기

diff --git a/docs/llms-full.txt b/docs/llms-full.txt index e028895..57e5891 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.14.0, macOS 15 or later. + Everything from the first launch to tuning the agent rules. Flowlight 0.14.1, macOS 15 or later. On this page @@ -853,7 +853,7 @@ Understand your AI agents. brew install --cask xinbetween/tap/flowlightCopy - v0.14.0macOS 15+UniversalGPL-3.0No telemetry + v0.14.1macOS 15+UniversalGPL-3.0No telemetry connectionslive @@ -1536,9 +1536,23 @@ Releases Downloads, checksums and full notes for each version are on GitHub Releases. - 0.14.0 + 0.14.1 October 9, 2026Latest + Install local plugin packages. Settings › Plugins can now import and export Flowlight plugin + manifest packages, keep installed plugins split by official or third-party publisher, and reject packages that + try to reuse a reserved built-in plugin ID. + + Configure and maintain plugin records. Plugin packages can store local string key/value + configuration, installed packages can be removed with their retained findings, and built-in plugins preserve + enable/configuration state while the app updates their manifest metadata. + + Backfill advisory findings. The Plugins tab can rerun official built-in rules over retained + inspected exchanges so newly enabled or updated plugins can annotate recent history without recapturing traffic. + + 0.14.0 +October 9, 2026 + Official inspection plugins. Flowlight now derives advisory plugin findings from inspected exchanges after capture, redaction and attribution. The first official built-ins cover large uploads, destructive-looking HTTP requests, credential-like header names, direct HTTP MCP activity, provider-run MCP diff --git a/docs/llms.txt b/docs/llms.txt index b4bf8a6..970729c 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.14.0. +> 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.14.1. - [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 43401ad..752b41c 100644 --- a/docs/plugins/index.html +++ b/docs/plugins/index.html @@ -183,7 +183,7 @@

Review checklist

diff --git a/docs/privacy/index.html b/docs/privacy/index.html index 0add57e..d4372a0 100644 --- a/docs/privacy/index.html +++ b/docs/privacy/index.html @@ -195,7 +195,7 @@

Contact

diff --git a/docs/pt-PT/about/index.html b/docs/pt-PT/about/index.html index d8883b6..dee8395 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 069e5af..587e928 100644 --- a/docs/pt-PT/docs/index.html +++ b/docs/pt-PT/docs/index.html @@ -76,7 +76,7 @@

Documentação

Usar o Flowlight

-

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

+

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

diff --git a/docs/pt-PT/index.html b/docs/pt-PT/index.html index bc0afe6..8aeac6e 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.14.0macOS 15+UniversalGPL-3.0Sem telemetria

+

v0.14.1macOS 15+UniversalGPL-3.0Sem telemetria

@@ -571,7 +571,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 67068e7..5dd7bd1 100644 --- a/docs/pt-PT/privacy/index.html +++ b/docs/pt-PT/privacy/index.html @@ -188,7 +188,7 @@

Contacto

diff --git a/docs/pt-PT/threat-model/index.html b/docs/pt-PT/threat-model/index.html index 81f8247..7043e7b 100644 --- a/docs/pt-PT/threat-model/index.html +++ b/docs/pt-PT/threat-model/index.html @@ -225,7 +225,7 @@

Comunicar uma vulnerabilidade

diff --git a/docs/releases/index.html b/docs/releases/index.html index 413fca4..041a871 100644 --- a/docs/releases/index.html +++ b/docs/releases/index.html @@ -61,7 +61,21 @@

What's new

-

0.14.0

Latest
+

0.14.1

Latest
+
    +
  • Install local plugin packages. Settings › Plugins can now import and export Flowlight plugin + manifest packages, keep installed plugins split by official or third-party publisher, and reject packages that + try to reuse a reserved built-in plugin ID.
  • +
  • Configure and maintain plugin records. Plugin packages can store local string key/value + configuration, installed packages can be removed with their retained findings, and built-in plugins preserve + enable/configuration state while the app updates their manifest metadata.
  • +
  • Backfill advisory findings. The Plugins tab can rerun official built-in rules over retained + inspected exchanges so newly enabled or updated plugins can annotate recent history without recapturing traffic.
  • +
+
+ +
+

0.14.0

  • Official inspection plugins. Flowlight now derives advisory plugin findings from inspected exchanges after capture, redaction and attribution. The first official built-ins cover large uploads, @@ -1141,7 +1155,7 @@

    What's new

diff --git a/docs/threat-model/index.html b/docs/threat-model/index.html index acc82e0..c6c7028 100644 --- a/docs/threat-model/index.html +++ b/docs/threat-model/index.html @@ -224,7 +224,7 @@

Reporting a vulnerability

diff --git a/docs/zh-Hans/about/index.html b/docs/zh-Hans/about/index.html index b4b1fb0..4d14bff 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 0662941..7f249dd 100644 --- a/docs/zh-Hans/docs/index.html +++ b/docs/zh-Hans/docs/index.html @@ -76,7 +76,7 @@

文档

使用 Flowlight

-

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

+

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

diff --git a/docs/zh-Hans/index.html b/docs/zh-Hans/index.html index e2b771d..acb32d0 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.14.0macOS 15+通用架构GPL-3.0无遥测

+

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

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

知道什么离开了你的 Mac。

diff --git a/docs/zh-Hans/privacy/index.html b/docs/zh-Hans/privacy/index.html index c3e380f..6ee7508 100644 --- a/docs/zh-Hans/privacy/index.html +++ b/docs/zh-Hans/privacy/index.html @@ -186,7 +186,7 @@

联系方式

diff --git a/docs/zh-Hans/threat-model/index.html b/docs/zh-Hans/threat-model/index.html index 57a6440..37c06da 100644 --- a/docs/zh-Hans/threat-model/index.html +++ b/docs/zh-Hans/threat-model/index.html @@ -212,7 +212,7 @@

报告漏洞

diff --git a/docs/zh-Hant/about/index.html b/docs/zh-Hant/about/index.html index d02fd5a..1eb5663 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 a9300eb..93bd875 100644 --- a/docs/zh-Hant/docs/index.html +++ b/docs/zh-Hant/docs/index.html @@ -76,7 +76,7 @@

說明文件

使用 Flowlight

-

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

+

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

diff --git a/docs/zh-Hant/index.html b/docs/zh-Hant/index.html index 3636e88..7ae94d6 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.14.0macOS 15+通用架構GPL-3.0無遙測

+

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

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

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

diff --git a/docs/zh-Hant/privacy/index.html b/docs/zh-Hant/privacy/index.html index 71bb783..fd60d2f 100644 --- a/docs/zh-Hant/privacy/index.html +++ b/docs/zh-Hant/privacy/index.html @@ -186,7 +186,7 @@

聯絡

diff --git a/docs/zh-Hant/threat-model/index.html b/docs/zh-Hant/threat-model/index.html index bafba42..b6a6cb9 100644 --- a/docs/zh-Hant/threat-model/index.html +++ b/docs/zh-Hant/threat-model/index.html @@ -213,7 +213,7 @@

回報漏洞

diff --git a/project.yml b/project.yml index ad0d9e0..721bdbe 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.14.0" - CURRENT_PROJECT_VERSION: "30" + MARKETING_VERSION: "0.14.1" + CURRENT_PROJECT_VERSION: "31" targets: Flowlight: type: application diff --git a/site/pages/releases.html b/site/pages/releases.html index a1b4e29..7f33e7f 100644 --- a/site/pages/releases.html +++ b/site/pages/releases.html @@ -13,7 +13,21 @@

What's new

-

0.14.0

Latest
+

0.14.1

Latest
+
    +
  • Install local plugin packages. Settings › Plugins can now import and export Flowlight plugin + manifest packages, keep installed plugins split by official or third-party publisher, and reject packages that + try to reuse a reserved built-in plugin ID.
  • +
  • Configure and maintain plugin records. Plugin packages can store local string key/value + configuration, installed packages can be removed with their retained findings, and built-in plugins preserve + enable/configuration state while the app updates their manifest metadata.
  • +
  • Backfill advisory findings. The Plugins tab can rerun official built-in rules over retained + inspected exchanges so newly enabled or updated plugins can annotate recent history without recapturing traffic.
  • +
+
+ +
+

0.14.0

  • Official inspection plugins. Flowlight now derives advisory plugin findings from inspected exchanges after capture, redaction and attribution. The first official built-ins cover large uploads, From 661841cc8a85ae41388c93bea292bd9b952c244a Mon Sep 17 00:00:00 2001 From: blessdyb Date: Fri, 9 Oct 2026 09:25:34 -0700 Subject: [PATCH 3/4] Use compatible plugin removal dialog Co-Authored-By: Claude Code --- Flowlight/UI/PluginSettingsView.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Flowlight/UI/PluginSettingsView.swift b/Flowlight/UI/PluginSettingsView.swift index f9de169..f5ba69a 100644 --- a/Flowlight/UI/PluginSettingsView.swift +++ b/Flowlight/UI/PluginSettingsView.swift @@ -38,10 +38,17 @@ struct PluginSettingsView: View { } message: { Text(L("Recorded requests, response bodies, rules, guardrails, and plugin enable settings stay in place.")) } - .confirmationDialog(L("Remove installed plugin?"), item: $removing, titleVisibility: .visible) { plugin in - Button(L("Remove %@", plugin.name), role: .destructive) { monitor.plugins.removeInstalled(plugin) } - } message: { plugin in - Text(L("%@ and its retained findings will be removed. Built-in plugins cannot be removed.", plugin.name)) + .confirmationDialog(L("Remove installed plugin?"), isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), titleVisibility: .visible) { + if let plugin = removing { + Button(L("Remove %@", plugin.name), role: .destructive) { + monitor.plugins.removeInstalled(plugin) + removing = nil + } + } + } message: { + if let plugin = removing { + Text(L("%@ and its retained findings will be removed. Built-in plugins cannot be removed.", plugin.name)) + } } .sheet(item: $configuring) { plugin in PluginConfigurationSheet(plugin: plugin) { configuration in From 99858a152e8f869f51c02c2034f478cc7fbb736b Mon Sep 17 00:00:00 2001 From: blessdyb Date: Fri, 9 Oct 2026 09:47:27 -0700 Subject: [PATCH 4/4] Use JavaScript plugin template validator Co-Authored-By: Claude Code --- docs/llms-full.txt | 6 +-- docs/plugins/index.html | 6 +-- site/pages/plugins.html | 6 +-- templates/inspection-plugin/README.md | 4 +- templates/inspection-plugin/validate.js | 71 +++++++++++++++++++++++++ templates/inspection-plugin/validate.py | 65 ---------------------- 6 files changed, 82 insertions(+), 76 deletions(-) create mode 100644 templates/inspection-plugin/validate.js delete mode 100644 templates/inspection-plugin/validate.py diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 57e5891..9fe7586 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -1400,10 +1400,10 @@ Plugins cp -R templates/inspection-plugin my-flowlight-plugin cd my-flowlight-plugin -python3 -m json.tool manifest.json -python3 validate.py +node -e "JSON.parse(require('fs').readFileSync('manifest.json'))" +node validate.js - The validator is deliberately local and boring: it checks manifest fields, provider names, capability names, evidence size limits and the absence of common secret-looking values in sample findings. + The JavaScript validator is deliberately local and boring: it checks manifest fields, provider names, capability names, evidence size limits and the absence of common secret-looking values in sample findings. Review checklist diff --git a/docs/plugins/index.html b/docs/plugins/index.html index 752b41c..cc34994 100644 --- a/docs/plugins/index.html +++ b/docs/plugins/index.html @@ -136,9 +136,9 @@

    Template package

    The starter package lives at templates/inspection-plugin. It includes a manifest, sample findings, a policy checklist and fixtures for ordinary traffic and LLM/MCP guardrail plugins. Import the manifest JSON from Settings › Plugins, then export it again to share the stored package shape.

    cp -R templates/inspection-plugin my-flowlight-plugin
     cd my-flowlight-plugin
    -python3 -m json.tool manifest.json
    -python3 validate.py
    -

    The validator is deliberately local and boring: it checks manifest fields, provider names, capability names, evidence size limits and the absence of common secret-looking values in sample findings.

    +node -e "JSON.parse(require('fs').readFileSync('manifest.json'))" +node validate.js

+

The JavaScript validator is deliberately local and boring: it checks manifest fields, provider names, capability names, evidence size limits and the absence of common secret-looking values in sample findings.

Review checklist

    diff --git a/site/pages/plugins.html b/site/pages/plugins.html index 55dbfb2..2761ab2 100644 --- a/site/pages/plugins.html +++ b/site/pages/plugins.html @@ -88,9 +88,9 @@

    Template package

    The starter package lives at templates/inspection-plugin. It includes a manifest, sample findings, a policy checklist and fixtures for ordinary traffic and LLM/MCP guardrail plugins. Import the manifest JSON from Settings › Plugins, then export it again to share the stored package shape.

    cp -R templates/inspection-plugin my-flowlight-plugin
     cd my-flowlight-plugin
    -python3 -m json.tool manifest.json
    -python3 validate.py
    -

    The validator is deliberately local and boring: it checks manifest fields, provider names, capability names, evidence size limits and the absence of common secret-looking values in sample findings.

    +node -e "JSON.parse(require('fs').readFileSync('manifest.json'))" +node validate.js
+

The JavaScript validator is deliberately local and boring: it checks manifest fields, provider names, capability names, evidence size limits and the absence of common secret-looking values in sample findings.

Review checklist

    diff --git a/templates/inspection-plugin/README.md b/templates/inspection-plugin/README.md index 500187c..2693683 100644 --- a/templates/inspection-plugin/README.md +++ b/templates/inspection-plugin/README.md @@ -12,7 +12,7 @@ Evidence must be bounded and redacted. Do not include raw request or response bo - `manifest.json`: plugin identity, publisher category, guardrail provider, privacy summary, optional `configuration` string map and capabilities. - `findings.example.json`: sample findings the plugin may produce. -- `validate.py`: local schema and privacy sanity checks for the template package. +- `validate.js`: local schema and privacy sanity checks for the template package. ## Supported guardrail providers @@ -28,5 +28,5 @@ Evidence must be bounded and redacted. Do not include raw request or response bo ## Validate ```sh -python3 validate.py +node validate.js ``` diff --git a/templates/inspection-plugin/validate.js b/templates/inspection-plugin/validate.js new file mode 100644 index 0000000..779b349 --- /dev/null +++ b/templates/inspection-plugin/validate.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); + +const root = __dirname; +const kinds = new Set(['traffic', 'llmMCP']); +const sources = new Set(['builtIn', 'installed']); +const publishers = new Set(['official', 'thirdParty']); +const providers = new Set(['flowlight', 'presidio', 'bedrock', 'lakera', 'aporia', 'noma', 'prismaAIRS', 'custom']); +const capabilities = new Set(['annotate', 'suggestGuardrail']); +const severities = new Set(['info', 'low', 'medium', 'high']); +const secretPattern = /(sk-[A-Za-z0-9_-]{8,}|bearer\s+\S+|api[_-]?key\s*[:=]|secret\s*[:=]|token\s*[:=])/i; + +function load(name) { + return JSON.parse(fs.readFileSync(path.join(root, name), 'utf8')); +} + +function fail(message) { + console.error(`error: ${message}`); + process.exit(1); +} + +function requireCondition(condition, message) { + if (!condition) fail(message); +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function checkManifest(manifest) { + for (const key of ['id', 'name', 'version', 'kind', 'source', 'publisher', 'guardrailProvider', 'description', 'privacySummary', 'capabilities']) { + requireCondition(Object.prototype.hasOwnProperty.call(manifest, key), `manifest missing ${key}`); + } + requireCondition(/^[A-Za-z0-9_.-]+$/.test(manifest.id), 'manifest id must use letters, numbers, dots, underscores, and hyphens'); + requireCondition(kinds.has(manifest.kind), 'manifest kind must be traffic or llmMCP'); + requireCondition(sources.has(manifest.source), 'manifest source must be builtIn or installed'); + requireCondition(publishers.has(manifest.publisher), 'manifest publisher must be official or thirdParty'); + requireCondition(providers.has(manifest.guardrailProvider), 'unsupported guardrailProvider'); + requireCondition(Array.isArray(manifest.capabilities), 'manifest capabilities must be an array'); + requireCondition(manifest.capabilities.every(capability => capabilities.has(capability)), 'unsupported capability'); + requireCondition(manifest.privacySummary.length <= 500, 'privacySummary should stay concise'); + if (manifest.configuration !== undefined) { + requireCondition(isPlainObject(manifest.configuration), 'configuration must be an object'); + for (const [key, value] of Object.entries(manifest.configuration)) { + requireCondition(typeof value === 'string', `configuration ${key} must be a string`); + } + } +} + +function checkFindings(findings) { + requireCondition(Array.isArray(findings), 'findings.example.json must be an array'); + findings.forEach((finding, index) => { + requireCondition(severities.has(finding.severity), `finding ${index} has unsupported severity`); + for (const key of ['title', 'summary', 'evidence']) { + requireCondition(Object.prototype.hasOwnProperty.call(finding, key), `finding ${index} missing ${key}`); + } + requireCondition(finding.title.length <= 120, `finding ${index} title is too long`); + requireCondition(finding.summary.length <= 500, `finding ${index} summary is too long`); + requireCondition(Array.isArray(finding.evidence) && finding.evidence.length <= 8, `finding ${index} evidence must be a short array`); + requireCondition(!secretPattern.test(JSON.stringify(finding)), `finding ${index} appears to contain a secret-like value`); + finding.evidence.forEach(evidence => { + requireCondition((evidence.label || '').length <= 80, `finding ${index} evidence label is too long`); + requireCondition((evidence.value || '').length <= 240, `finding ${index} evidence value is too long`); + }); + }); +} + +checkManifest(load('manifest.json')); +checkFindings(load('findings.example.json')); +console.log('ok'); diff --git a/templates/inspection-plugin/validate.py b/templates/inspection-plugin/validate.py deleted file mode 100644 index 8000fa6..0000000 --- a/templates/inspection-plugin/validate.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -import json -import re -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parent -KINDS = {"traffic", "llmMCP"} -SOURCES = {"builtIn", "installed"} -PUBLISHERS = {"official", "thirdParty"} -PROVIDERS = {"flowlight", "presidio", "bedrock", "lakera", "aporia", "noma", "prismaAIRS", "custom"} -CAPABILITIES = {"annotate", "suggestGuardrail"} -SEVERITIES = {"info", "low", "medium", "high"} -SECRET_RE = re.compile(r"(sk-[A-Za-z0-9_-]{8,}|bearer\s+\S+|api[_-]?key\s*[:=]|secret\s*[:=]|token\s*[:=])", re.I) - - -def load(name): - return json.loads((ROOT / name).read_text()) - - -def fail(message): - print(f"error: {message}", file=sys.stderr) - sys.exit(1) - - -def require(condition, message): - if not condition: - fail(message) - - -def check_manifest(manifest): - for key in ["id", "name", "version", "kind", "source", "publisher", "guardrailProvider", "description", "privacySummary", "capabilities"]: - require(key in manifest, f"manifest missing {key}") - require(manifest["kind"] in KINDS, "manifest kind must be traffic or llmMCP") - require(manifest["source"] in SOURCES, "manifest source must be builtIn or installed") - require(manifest["publisher"] in PUBLISHERS, "manifest publisher must be official or thirdParty") - require(manifest["guardrailProvider"] in PROVIDERS, "unsupported guardrailProvider") - require(set(manifest["capabilities"]).issubset(CAPABILITIES), "unsupported capability") - require(len(manifest["privacySummary"]) <= 500, "privacySummary should stay concise") - - -def check_findings(findings): - require(isinstance(findings, list), "findings.example.json must be an array") - for i, finding in enumerate(findings): - require(finding.get("severity") in SEVERITIES, f"finding {i} has unsupported severity") - for key in ["title", "summary", "evidence"]: - require(key in finding, f"finding {i} missing {key}") - require(len(finding["title"]) <= 120, f"finding {i} title is too long") - require(len(finding["summary"]) <= 500, f"finding {i} summary is too long") - require(isinstance(finding["evidence"], list) and len(finding["evidence"]) <= 8, f"finding {i} evidence must be a short array") - text = json.dumps(finding, ensure_ascii=False) - require(not SECRET_RE.search(text), f"finding {i} appears to contain a secret-like value") - for evidence in finding["evidence"]: - require(len(evidence.get("label", "")) <= 80, f"finding {i} evidence label is too long") - require(len(evidence.get("value", "")) <= 240, f"finding {i} evidence value is too long") - - -def main(): - check_manifest(load("manifest.json")) - check_findings(load("findings.example.json")) - print("ok") - - -if __name__ == "__main__": - main()