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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Flowlight/Plugins/PluginEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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
Expand Down
26 changes: 24 additions & 2 deletions Flowlight/Plugins/PluginModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand Down
80 changes: 80 additions & 0 deletions Flowlight/Plugins/PluginStore.swift
Original file line number Diff line number Diff line change
@@ -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] = []
Expand Down Expand Up @@ -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<PluginImportResult, Error>) -> 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<PluginBackfillResult, Error>) -> 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()
Expand Down
35 changes: 35 additions & 0 deletions Flowlight/Storage/TrafficDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading