diff --git a/Flowlight/App/TrafficMonitor.swift b/Flowlight/App/TrafficMonitor.swift index 4ade6147..47471ae7 100644 --- a/Flowlight/App/TrafficMonitor.swift +++ b/Flowlight/App/TrafficMonitor.swift @@ -56,6 +56,8 @@ final class TrafficMonitor: ObservableObject { let rules = RuleStore() /// 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. + let plugins = PluginStore() /// Channels that aren't the network: Bluetooth, USB. Both off until switched on. let devices = DeviceStore() /// Questions in plain language, answered from the recorded history. @@ -157,6 +159,7 @@ final class TrafficMonitor: ObservableObject { Task { @MainActor in self?.rules.record([event]) } } guardrails.attach(db: db) + plugins.attach(db: db) devices.attach(db: db) ask.attach(db: readDB) ask.environment = { [weak self] in self?.askEnvironment() ?? .empty } @@ -638,6 +641,10 @@ final class TrafficMonitor: ObservableObject { Task { await localRisk.backfillRecent() } } + func clearPluginFindings() { + plugins.clearFindings() + } + func clearLocalRiskAnalyses() { Task { [weak self, db] in db.async { db in diff --git a/Flowlight/Plugins/PluginEngine.swift b/Flowlight/Plugins/PluginEngine.swift new file mode 100644 index 00000000..688c9f44 --- /dev/null +++ b/Flowlight/Plugins/PluginEngine.swift @@ -0,0 +1,187 @@ +import Foundation + +protocol InspectionPluginRule: Sendable { + var manifest: PluginManifest { get } + func evaluate(_ context: PluginContext) -> [PluginFindingDraft] +} + +struct PluginContext: Sendable { + var exchange: HTTPExchange + var requestHeaderNames: Set + var responseHeaderNames: Set + var pathWithoutQuery: String + + init(exchange: HTTPExchange) { + self.exchange = exchange + requestHeaderNames = Set(exchange.requestHeaders.map { $0.name.lowercased() }) + responseHeaderNames = Set(exchange.responseHeaders.map { $0.name.lowercased() }) + pathWithoutQuery = exchange.path.split(separator: "?", maxSplits: 1).first.map(String.init) ?? exchange.path + } + + var agentLabel: String { exchange.agentName ?? exchange.agent ?? exchange.appName } + var isLLMOrMCP: Bool { exchange.llm != nil || !exchange.toolCalls.isEmpty || !exchange.mcp.isEmpty || exchange.mcpServer != nil } +} + +enum PluginEngine { + static let builtInRules: [any InspectionPluginRule] = [ + LargeUploadRule(), + RiskyMethodRule(), + SensitiveHeaderRule(), + MCPOverHTTPRule(), + ProviderConnectorRule(), + HighRiskToolRule(), + ] + + static var builtInManifests: [PluginManifest] { builtInRules.map(\.manifest) } + + static func evaluate(_ exchange: HTTPExchange, manifests: [PluginManifest]) -> [PluginFindingDraft] { + 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 [] } + return rule.evaluate(context) + } + } + + static func mergedManifests(_ stored: [PluginManifest]) -> [PluginManifest] { + var byID = Dictionary(uniqueKeysWithValues: stored.map { ($0.id, $0) }) + for builtIn in builtInManifests { + if var existing = byID[builtIn.id] { + let enabled = existing.enabled + existing = builtIn + existing.enabled = enabled + byID[builtIn.id] = existing + } else { + byID[builtIn.id] = builtIn + } + } + return byID.values.sorted { $0.name < $1.name } + } +} + +private extension InspectionPluginRule { + func finding(_ severity: PluginFinding.Severity, _ title: String, _ summary: String, + _ evidence: [PluginEvidence], suggestedGuardrail: Guardrail? = nil) -> PluginFindingDraft { + PluginFindingDraft(pluginID: manifest.id, pluginVersion: manifest.version, kind: manifest.kind, severity: severity, + title: title, summary: summary, evidence: evidence, suggestedGuardrail: suggestedGuardrail) + } +} + +private struct LargeUploadRule: InspectionPluginRule { + let manifest = PluginManifest( + id: "traffic.large-upload", name: "Large uploads", version: "1.0.0", kind: .traffic, + description: L("Flags inspected requests that upload unusually large payloads."), + privacySummary: L("Uses request size, method, app and host metadata only.")) + + func evaluate(_ context: PluginContext) -> [PluginFindingDraft] { + guard context.exchange.requestSize >= 512 * 1024 else { return [] } + return [finding(.medium, L("Large upload"), + L("%@ sent %@ in one inspected %@ request.", context.exchange.appName, ByteCountFormatter.string(fromByteCount: Int64(context.exchange.requestSize), countStyle: .file), context.exchange.method), + [PluginEvidence(L("Host"), context.exchange.host), PluginEvidence(L("Method"), context.exchange.method), + PluginEvidence(L("Request size"), ByteCountFormatter.string(fromByteCount: Int64(context.exchange.requestSize), countStyle: .file))])] + } +} + +private struct RiskyMethodRule: InspectionPluginRule { + let manifest = PluginManifest( + id: "traffic.risky-method", name: "Risky HTTP methods", version: "1.0.0", kind: .traffic, + description: L("Highlights destructive-looking HTTP requests."), + privacySummary: L("Uses method, redacted path shape and host metadata only.")) + + func evaluate(_ context: PluginContext) -> [PluginFindingDraft] { + let method = context.exchange.method.uppercased() + guard ["DELETE", "PATCH", "PUT"].contains(method) else { return [] } + let path = context.pathWithoutQuery.lowercased() + let interesting = ["/admin", "/delete", "/destroy", "/token", "/key", "/secret", "/user"].contains { path.contains($0) } + guard interesting || method == "DELETE" else { return [] } + return [finding(method == "DELETE" ? .medium : .low, L("Destructive-looking request"), + L("%@ made a %@ request to a sensitive-looking path on %@.", context.exchange.appName, method, context.exchange.host), + [PluginEvidence(L("Host"), context.exchange.host), PluginEvidence(L("Method"), method), + PluginEvidence(L("Path"), context.pathWithoutQuery, limit: 160)])] + } +} + +private struct SensitiveHeaderRule: InspectionPluginRule { + let manifest = PluginManifest( + id: "traffic.sensitive-headers", name: "Sensitive header names", version: "1.0.0", kind: .traffic, + description: L("Notes requests carrying credential-like header names."), + privacySummary: L("Reads header names only; values are never exposed.")) + + func evaluate(_ context: PluginContext) -> [PluginFindingDraft] { + let sensitive = context.requestHeaderNames.filter { name in + name == "authorization" || name == "cookie" || name.contains("api-key") || name.contains("token") || name.contains("secret") + }.sorted() + guard !sensitive.isEmpty else { return [] } + return [finding(.info, L("Credential-like headers present"), + L("This request included credential-like header names. Values remain redacted."), + [PluginEvidence(L("Header names"), sensitive.joined(separator: ", "))])] + } +} + +private struct MCPOverHTTPRule: InspectionPluginRule { + let manifest = PluginManifest( + id: "llm-mcp.http-mcp", name: "HTTP MCP activity", version: "1.0.0", kind: .llmMCP, + description: L("Surfaces direct MCP activity observed over inspected HTTP."), + privacySummary: L("Uses MCP method, server and tool names only.")) + + func evaluate(_ context: PluginContext) -> [PluginFindingDraft] { + guard !context.exchange.mcp.isEmpty else { return [] } + let methods = Set(context.exchange.mcp.map(\.method)).sorted().joined(separator: ", ") + return [finding(.info, L("MCP over HTTP observed"), + L("%@ used MCP over inspected HTTP with %@.", context.agentLabel, context.exchange.host), + [PluginEvidence(L("Methods"), methods), PluginEvidence(L("Host"), context.exchange.host)])] + } +} + +private struct ProviderConnectorRule: InspectionPluginRule { + let manifest = PluginManifest( + id: "llm-mcp.provider-connectors", name: "Provider-run MCP connectors", version: "1.0.0", kind: .llmMCP, + description: L("Explains MCP connectors that run on the model provider side."), + privacySummary: L("Uses connector labels, provider names and approval metadata only.")) + + func evaluate(_ context: PluginContext) -> [PluginFindingDraft] { + guard let llm = context.exchange.llm, !llm.connectors.isEmpty else { return [] } + return llm.connectors.map { connector in + var evidence = [PluginEvidence(L("Provider"), connector.provider), PluginEvidence(L("Connector"), connector.label)] + if let approval = connector.approval { evidence.append(PluginEvidence(L("Approval"), approval)) } + if connector.authorized { evidence.append(PluginEvidence(L("Authorization"), L("configured"))) } + let severity: PluginFinding.Severity = connector.authorized ? .medium : .low + return finding(severity, L("Provider-run MCP connector"), + L("%@ offered %@ through %@. Its network calls run outside this Mac, so local network rules may not see them.", + context.agentLabel, connector.label, connector.provider), evidence) + } + } +} + +private struct HighRiskToolRule: InspectionPluginRule { + let manifest = PluginManifest( + id: "llm-mcp.high-risk-tools", name: "High-risk tools", version: "1.0.0", kind: .llmMCP, + description: L("Suggests guardrails for declared or used tools with write, shell or delete capability."), + privacySummary: L("Uses tool and MCP server names only, not tool arguments."), capabilities: [.annotate, .suggestGuardrail]) + + private let patterns = ["bash", "shell", "exec", "terminal", "write", "edit", "delete", "remove", "create", "update", "patch", "apply"] + + func evaluate(_ context: PluginContext) -> [PluginFindingDraft] { + var tools: [(name: String, server: String?)] = [] + if let llm = context.exchange.llm { + tools += llm.declaredTools.compactMap { tool in + guard tool.kind == .function else { return nil } + return (tool.name, tool.server) + } + } + tools += context.exchange.toolCalls.map { ($0.name, $0.mcpServer) } + var seen = Set() + return tools.compactMap { tool in + let key = [tool.server ?? "", tool.name].joined(separator: "/") + guard seen.insert(key).inserted else { return nil } + let lower = tool.name.lowercased() + guard patterns.contains(where: { lower.contains($0) }) else { return nil } + var guardrail = Guardrail(agent: context.exchange.agent ?? "", server: tool.server ?? "", tool: tool.name, origin: .observed) + guardrail.name = L("Review %@", tool.server.map { "\($0) › \(tool.name)" } ?? tool.name) + return finding(.medium, L("High-risk tool available"), + L("%@ declared or used %@. Add a guardrail if this tool should be refused.", context.agentLabel, tool.server.map { "\($0) › \(tool.name)" } ?? tool.name), + [PluginEvidence(L("Tool"), tool.name), PluginEvidence(L("Server"), tool.server ?? L("agent tool"))], + suggestedGuardrail: guardrail) + } + } +} diff --git a/Flowlight/Plugins/PluginModels.swift b/Flowlight/Plugins/PluginModels.swift new file mode 100644 index 00000000..72fea8cc --- /dev/null +++ b/Flowlight/Plugins/PluginModels.swift @@ -0,0 +1,140 @@ +import Foundation + +/// Advisory plugin definitions and findings. Plugins do not alter traffic; they add derived context to inspected exchanges. +struct PluginManifest: Codable, Identifiable, Equatable, Sendable { + var id: String + var name: String + var version: String + var kind: Kind + var enabled: Bool = true + var source: Source = .builtIn + var publisher: Publisher = .official + var guardrailProvider: GuardrailProvider = .flowlight + var description: String + var ruleVersion: Int = 1 + var privacySummary: String + var capabilities: [Capability] = [.annotate] + + enum Kind: String, Codable, Sendable { case traffic, llmMCP } + enum Source: String, Codable, Sendable { case builtIn, installed } + enum Publisher: String, Codable, Sendable { case official, thirdParty } + enum GuardrailProvider: String, Codable, Sendable, CaseIterable { + case flowlight, presidio, bedrock, lakera, aporia, noma, prismaAIRS, custom + + var title: String { + switch self { + case .flowlight: return "Flowlight" + case .presidio: return "Presidio" + case .bedrock: return "Bedrock" + case .lakera: return "Lakera" + case .aporia: return "Aporia" + case .noma: return "Noma" + case .prismaAIRS: return "PANW Prisma AIRS" + case .custom: return "Custom" + } + } + } + enum Capability: String, Codable, Sendable { case annotate, suggestGuardrail } + + 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]) { + self.id = id + self.name = name + self.version = version + self.kind = kind + self.enabled = enabled + self.source = source + self.publisher = publisher + self.guardrailProvider = guardrailProvider + self.description = description + self.ruleVersion = ruleVersion + self.privacySummary = privacySummary + self.capabilities = capabilities + } + + private enum CodingKeys: String, CodingKey { + case id, name, version, kind, enabled, source, publisher, guardrailProvider, description, ruleVersion, privacySummary, capabilities + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + name = try values.decode(String.self, forKey: .name) + version = try values.decode(String.self, forKey: .version) + kind = try values.decode(Kind.self, forKey: .kind) + enabled = try values.decodeIfPresent(Bool.self, forKey: .enabled) ?? true + source = try values.decodeIfPresent(Source.self, forKey: .source) ?? .builtIn + publisher = try values.decodeIfPresent(Publisher.self, forKey: .publisher) ?? .official + guardrailProvider = try values.decodeIfPresent(GuardrailProvider.self, forKey: .guardrailProvider) ?? .flowlight + description = try values.decode(String.self, forKey: .description) + 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] + } +} + +struct PluginEvidence: Codable, Equatable, Sendable { + var label: String + var value: String + + init(_ label: String, _ value: String, limit: Int = 240) { + self.label = String(label.prefix(80)) + self.value = String(value.prefix(limit)) + } +} + +struct PluginFinding: Codable, Identifiable, Equatable, Sendable { + var id: String + var exchangeID: Int64 + var pluginID: String + var pluginVersion: String + var kind: PluginManifest.Kind + var severity: Severity + var title: String + var summary: String + var evidence: [PluginEvidence] + var suggestedGuardrail: Guardrail? + var createdAt: Date + + enum Severity: String, Codable, Sendable, CaseIterable { case info, low, medium, high } +} + +struct PluginFindingDraft: Equatable, Sendable { + var pluginID: String + var pluginVersion: String + var kind: PluginManifest.Kind + var severity: PluginFinding.Severity + var title: String + var summary: String + var evidence: [PluginEvidence] + var suggestedGuardrail: Guardrail? + + func materialize(exchangeID: Int64, createdAt: Date = Date()) -> PluginFinding { + let stable = [String(exchangeID), pluginID, title].joined(separator: "|") + return PluginFinding(id: StablePluginID.make(stable), exchangeID: exchangeID, pluginID: pluginID, pluginVersion: pluginVersion, + kind: kind, severity: severity, title: title, summary: summary, evidence: evidence, + suggestedGuardrail: suggestedGuardrail, createdAt: createdAt) + } +} + +extension PluginManifest.Kind { + var title: String { + switch self { + case .traffic: return L("Traffic") + case .llmMCP: return L("LLM/MCP") + } + } +} + +private enum StablePluginID { + /// Deterministic enough for de-duplicating plugin findings without pulling in a hashing dependency. + static func make(_ text: String) -> String { + var hash: UInt64 = 0xcbf29ce484222325 + for byte in text.utf8 { + hash ^= UInt64(byte) + hash &*= 0x100000001b3 + } + return String(format: "%016llx", hash) + } +} diff --git a/Flowlight/Plugins/PluginStore.swift b/Flowlight/Plugins/PluginStore.swift new file mode 100644 index 00000000..94b69aa8 --- /dev/null +++ b/Flowlight/Plugins/PluginStore.swift @@ -0,0 +1,48 @@ +import Foundation + +@MainActor +final class PluginStore: ObservableObject { + @Published private(set) var manifests: [PluginManifest] = [] + @Published private(set) var version = 0 + + private weak var db: TrafficDatabase? + + func attach(db: TrafficDatabase) { + self.db = db + reload() + } + + func reload() { + guard let db else { return } + db.async { [weak self] db in + let merged = PluginEngine.mergedManifests((try? db.loadPluginManifests()) ?? []) + for manifest in merged where manifest.source == .builtIn { + try? db.savePluginManifest(manifest) + } + Task { @MainActor in + self?.manifests = merged + self?.version += 1 + } + } + } + + func setEnabled(_ manifest: PluginManifest, _ enabled: Bool) { + guard var updated = manifests.first(where: { $0.id == manifest.id }) else { return } + updated.enabled = enabled + if let index = manifests.firstIndex(where: { $0.id == updated.id }) { + manifests[index] = updated + } + db?.async { try $0.setPluginEnabled(id: updated.id, enabled: enabled) } + version += 1 + } + + func clearFindings() { + db?.async { [weak self] db in + try db.clearPluginFindings() + Task { @MainActor in self?.version += 1 } + } + } + + var officialPlugins: [PluginManifest] { manifests.filter { $0.publisher == .official } } + var thirdPartyPlugins: [PluginManifest] { manifests.filter { $0.publisher == .thirdParty } } +} diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift index 6c9ab6f9..9cdd715e 100644 --- a/Flowlight/Storage/TrafficDatabase.swift +++ b/Flowlight/Storage/TrafficDatabase.swift @@ -384,6 +384,28 @@ final class TrafficDatabase: @unchecked Sendable { ); CREATE INDEX IF NOT EXISTS local_risk_assessments_state_created ON local_risk_assessments (state, created_at); CREATE INDEX IF NOT EXISTS local_risk_assessments_severity_created ON local_risk_assessments (severity, created_at); + CREATE TABLE IF NOT EXISTS plugins ( + id TEXT PRIMARY KEY, + manifest TEXT NOT NULL, + enabled INTEGER NOT NULL, + updated INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS plugin_findings ( + id TEXT PRIMARY KEY, + exchange_id INTEGER NOT NULL, + plugin_id TEXT NOT NULL, + plugin_version TEXT NOT NULL, + kind TEXT NOT NULL, + severity TEXT NOT NULL, + title TEXT NOT NULL, + summary TEXT NOT NULL, + evidence TEXT NOT NULL, + suggested_guardrail TEXT NOT NULL DEFAULT '', + created_at REAL NOT NULL, + UNIQUE(exchange_id, plugin_id, title) + ); + CREATE INDEX IF NOT EXISTS plugin_findings_exchange ON plugin_findings (exchange_id); + CREATE INDEX IF NOT EXISTS plugin_findings_plugin_created ON plugin_findings (plugin_id, created_at); """) // Added with blocking: the destination a refused connection was headed for, so its alert can offer to // allow it without parsing the sentence back out of `detail`. @@ -974,6 +996,86 @@ final class TrafficDatabase: @unchecked Sendable { try conn.run("DELETE FROM guardrails WHERE id = ?", [.text(id.uuidString)]) } + // MARK: Plugins + + func loadPluginManifests() throws -> [PluginManifest] { + let rows = try conn.query("SELECT manifest, enabled FROM plugins ORDER BY updated") { ($0.text(0), $0.int(1) != 0) } + return rows.compactMap { text, enabled in + guard var manifest = try? JSONDecoder().decode(PluginManifest.self, from: Data(text.utf8)) else { return nil } + manifest.enabled = enabled + return manifest + } + } + + func savePluginManifest(_ manifest: PluginManifest) throws { + let json = String(decoding: try JSONEncoder().encode(manifest), as: UTF8.self) + try conn.run(""" + INSERT INTO plugins (id, manifest, enabled, updated) VALUES (?,?,?,?) + ON CONFLICT(id) DO UPDATE SET manifest = excluded.manifest, enabled = excluded.enabled, updated = excluded.updated + """, [.text(manifest.id), .text(json), .int(manifest.enabled ? 1 : 0), .int(Int64(Date().timeIntervalSince1970))]) + } + + func setPluginEnabled(id: String, enabled: Bool) throws { + if var manifest = try loadPluginManifests().first(where: { $0.id == id }) { + manifest.enabled = enabled + try savePluginManifest(manifest) + } else if var manifest = PluginEngine.builtInManifests.first(where: { $0.id == id }) { + manifest.enabled = enabled + try savePluginManifest(manifest) + } + } + + func insertPluginFindings(_ findings: [PluginFindingDraft], forExchangeID exchangeID: Int64) throws { + guard !findings.isEmpty else { return } + let encoder = JSONEncoder() + for draft in findings { + let finding = draft.materialize(exchangeID: exchangeID) + let evidence = String(decoding: try encoder.encode(finding.evidence), as: UTF8.self) + let guardrail: String + if let suggested = finding.suggestedGuardrail { + guardrail = String(decoding: try encoder.encode(suggested), as: UTF8.self) + } else { + guardrail = "" + } + try conn.run(""" + INSERT OR IGNORE INTO plugin_findings (id, exchange_id, plugin_id, plugin_version, kind, severity, title, summary, evidence, suggested_guardrail, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + """, [.text(finding.id), .int(exchangeID), .text(finding.pluginID), .text(finding.pluginVersion), .text(finding.kind.rawValue), + .text(finding.severity.rawValue), .text(finding.title), .text(String(finding.summary.prefix(500))), .text(evidence), + .text(guardrail), .double(finding.createdAt.timeIntervalSince1970)]) + } + } + + func pluginFindings(exchangeIDs: [Int64], visibleOnly: Bool = true) throws -> [Int64: [PluginFinding]] { + guard !exchangeIDs.isEmpty else { return [:] } + let manifests = try loadPluginManifests() + let enabled = Dictionary(uniqueKeysWithValues: PluginEngine.mergedManifests(manifests).map { ($0.id, $0.enabled) }) + let marks = Array(repeating: "?", count: exchangeIDs.count).joined(separator: ",") + let values = exchangeIDs.map(SQLValue.int) + let rows = try conn.query(""" + SELECT id, exchange_id, plugin_id, plugin_version, kind, severity, title, summary, evidence, suggested_guardrail, created_at + FROM plugin_findings WHERE exchange_id IN (\(marks)) ORDER BY created_at ASC, title ASC + """, values) { row in decodePluginFinding(row) } + let visible = visibleOnly ? rows.filter { enabled[$0.pluginID] ?? true } : rows + return Dictionary(grouping: visible, by: \.exchangeID) + } + + func clearPluginFindings() throws { + try conn.run("DELETE FROM plugin_findings") + } + + private func decodePluginFinding(_ row: SQLRow) -> PluginFinding { + let decoder = JSONDecoder() + let evidence = (try? decoder.decode([PluginEvidence].self, from: Data(row.text(8).utf8))) ?? [] + let guardrailText = row.text(9) + let guardrail = guardrailText.isEmpty ? nil : try? decoder.decode(Guardrail.self, from: Data(guardrailText.utf8)) + return PluginFinding(id: row.text(0), exchangeID: row.int(1), pluginID: row.text(2), pluginVersion: row.text(3), + kind: PluginManifest.Kind(rawValue: row.text(4)) ?? .traffic, + severity: PluginFinding.Severity(rawValue: row.text(5)) ?? .info, + title: row.text(6), summary: row.text(7), evidence: evidence, suggestedGuardrail: guardrail, + createdAt: Date(timeIntervalSince1970: row.double(10))) + } + // MARK: Rule simulation /// Recorded traffic in the shape the rule engine judges, for answering "what would this rule have done". @@ -1002,9 +1104,10 @@ final class TrafficDatabase: @unchecked Sendable { // MARK: HTTPS inspection - /// Inserts raw capture and, when deterministic triage finds enough independent evidence, creates its separate - /// derived job in the same transaction. This returns before any model work begins. + /// Inserts raw capture and, when deterministic triage or plugin rules find bounded evidence, creates separate + /// derived rows in the same transaction. This returns before any model work begins. func insertExchange(_ e: HTTPExchange, enqueueLocalRisk: Bool = false) throws -> Int64 { + var insertedID: Int64 = 0 try conn.transaction { let encoder = JSONEncoder() func json(_ v: T) -> String { (try? encoder.encode(v)).map { String(decoding: $0, as: UTF8.self) } ?? "[]" } @@ -1021,16 +1124,23 @@ final class TrafficDatabase: @unchecked Sendable { .text(e.agentName ?? ""), .text(e.mcpServer ?? ""), .text(e.toolCalls.isEmpty ? "" : json(e.toolCalls)), .text(e.note ?? ""), .text(e.toolResults.isEmpty ? "" : json(e.toolResults)), .text(e.mcp.isEmpty ? "" : json(e.mcp)), .text(e.llm.map(json) ?? ""), .text(e.mockRule ?? ""), .text(e.guardrail ?? ""), .text(e.responseTransform ?? "")]) - let id = try conn.query("SELECT last_insert_rowid()", map: { $0.int(0) }).first ?? 0 - if enqueueLocalRisk, LocalRiskSettings.enabled { - var stored = e - stored.id = id - if let candidate = LocalRiskTriage.candidate(for: stored) { - try enqueueLocalRiskCandidate(candidate) + insertedID = try conn.query("SELECT last_insert_rowid()", map: { $0.int(0) }).first ?? 0 + var stored = e + stored.id = insertedID + if enqueueLocalRisk, LocalRiskSettings.enabled, let candidate = LocalRiskTriage.candidate(for: stored) { + try enqueueLocalRiskCandidate(candidate) + } + do { + let manifests = PluginEngine.mergedManifests(try loadPluginManifests()) + for manifest in manifests where PluginEngine.builtInManifests.contains(where: { $0.id == manifest.id }) { + try savePluginManifest(manifest) } + try insertPluginFindings(PluginEngine.evaluate(stored, manifests: manifests), forExchangeID: insertedID) + } catch { + appLog.error("plugin evaluation skipped: \(String(describing: error), privacy: .public)") } } - return try conn.query("SELECT last_insert_rowid()", map: { $0.int(0) }).first ?? 0 + return insertedID } private func enqueueLocalRiskCandidate(_ candidate: RiskCandidate) throws { @@ -1267,6 +1377,8 @@ final class TrafficDatabase: @unchecked Sendable { try conn.transaction { try conn.run("DELETE FROM local_risk_assessments WHERE exchange_id IN (SELECT id FROM http_exchanges WHERE ts < ?)", [.double(cutoff.timeIntervalSince1970)]) + try conn.run("DELETE FROM plugin_findings WHERE exchange_id IN (SELECT id FROM http_exchanges WHERE ts < ?)", + [.double(cutoff.timeIntervalSince1970)]) try conn.run("DELETE FROM http_exchanges WHERE ts < ?", [.double(cutoff.timeIntervalSince1970)]) } } @@ -1274,6 +1386,7 @@ final class TrafficDatabase: @unchecked Sendable { func deleteAllExchanges() throws { try conn.transaction { try conn.run("DELETE FROM local_risk_assessments") + try conn.run("DELETE FROM plugin_findings") try conn.run("DELETE FROM http_exchanges") } } @@ -1380,7 +1493,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"] { + for t in Self.tables + ["rollup_state", "seen_destinations", "seen_ports", "apps", "baselines", "alerts", "http_exchanges", "local_risk_assessments", "plugin_findings"] { try conn.run("DELETE FROM \(t)") } } diff --git a/Flowlight/UI/InspectView.swift b/Flowlight/UI/InspectView.swift index dcd53e7b..81b83a2b 100644 --- a/Flowlight/UI/InspectView.swift +++ b/Flowlight/UI/InspectView.swift @@ -25,6 +25,7 @@ private struct InspectContent: View { @State private var scopeHost: String? @State private var potentialHarmOnly = false @State private var assessments: [Int64: RiskAssessment] = [:] + @State private var pluginFindings: [Int64: [PluginFinding]] = [:] @State private var window: AgentWindow = .day @State private var showSetup = false /// A rule plus safe, temporary captured context waiting in the mock editor. @@ -103,8 +104,9 @@ private struct InspectContent: View { .sheet(item: $responseTransformDraft) { draft in ResponseTransformRuleEditor(rule: draft.rule, isNew: true, draft: draft) { inspection.responseTransformRules.append($0) } } - .task(id: LoadKey(version: monitor.inspectionVersion, localRiskVersion: monitor.localRiskVersion, search: search, window: window, - enabled: inspection.enabled, potentialHarmOnly: potentialHarmOnly, focus: focus.scope, app: scopeApp?.id, host: scopeHost)) { + .task(id: LoadKey(version: monitor.inspectionVersion, localRiskVersion: monitor.localRiskVersion, pluginVersion: monitor.plugins.version, + search: search, window: window, enabled: inspection.enabled, + potentialHarmOnly: potentialHarmOnly, focus: focus.scope, app: scopeApp?.id, host: scopeHost)) { // Coalesce bursts of new exchanges. try? await Task.sleep(for: .milliseconds(300)) await load() @@ -112,7 +114,7 @@ private struct InspectContent: View { } private struct LoadKey: Equatable { - var version: Int; var localRiskVersion: Int; var search: String; var window: AgentWindow; var enabled: Bool + var version: Int; var localRiskVersion: Int; var pluginVersion: Int; var search: String; var window: AgentWindow; var enabled: Bool var potentialHarmOnly: Bool; var focus: FocusScope; var app: String?; var host: String? } @@ -150,10 +152,15 @@ private struct InspectContent: View { let loaded = (try? await monitor.read { try $0.exchanges(since: since, search: term, focus: scope, app: app, host: host) }) ?? [] + let ids = loaded.compactMap(\.id) let risk = (try? await monitor.read { - try $0.localRiskAssessments(exchangeIDs: loaded.compactMap(\.id)) + try $0.localRiskAssessments(exchangeIDs: ids) + }) ?? [:] + let findings = (try? await monitor.read { + try $0.pluginFindings(exchangeIDs: ids) }) ?? [:] assessments = risk + pluginFindings = findings exchanges = potentialHarmOnly ? loaded.filter { $0.id.flatMap { risk[$0] }?.isPotentialHarm == true } : loaded // `-FLInspectSelect paste.example`, so the published screenshot always shows the same request rather // than whichever one a click happened to land on. @@ -285,6 +292,10 @@ private struct InspectContent: View { RiskBadge(assessment: e.id.flatMap { assessments[$0] }) } .width(min: 76, ideal: 96) + TableColumn(L("Plugins")) { e in + PluginFindingBadge(findings: e.id.flatMap { pluginFindings[$0] } ?? []) + } + .width(min: 58, ideal: 68) TableColumn(L("Status")) { e in Text(e.status.map(String.init) ?? "–").monospacedDigit() .foregroundStyle((e.status ?? 0) >= 400 ? FL.critical : .primary) @@ -327,8 +338,10 @@ private struct InspectContent: View { @ViewBuilder private var detail: some View { if let selected = exchanges.first(where: { $0.id == selection }) { - ExchangeDetail(exchange: selected, assessment: selected.id.flatMap { assessments[$0] }, cause: selected.id.flatMap { links[$0] }, - results: results, highlight: search, mockThis: DemoData.isEnabled ? nil : { prepareMock(from: selected) }, + ExchangeDetail(exchange: selected, assessment: selected.id.flatMap { assessments[$0] }, + pluginFindings: selected.id.flatMap { pluginFindings[$0] } ?? [], + cause: selected.id.flatMap { links[$0] }, results: results, highlight: search, + mockThis: DemoData.isEnabled ? nil : { prepareMock(from: selected) }, modifyResponse: canModifyResponse(selected) ? { prepareResponseTransform(from: selected) } : nil) .id(selected.id) } else { @@ -533,11 +546,105 @@ private struct InspectionSetup: View { } } +private struct PluginFindingBadge: View { + var findings: [PluginFinding] + + var body: some View { + if findings.isEmpty { + Text(L("None")).font(.caption2).foregroundStyle(.tertiary) + } else { + Label("\(findings.count)", systemImage: topSeverity.symbol) + .labelStyle(.titleAndIcon) + .font(.caption2.bold()) + .foregroundStyle(topSeverity.color) + .help(findings.map(\.title).joined(separator: " · ")) + } + } + + private var topSeverity: PluginFinding.Severity { + findings.map(\.severity).max(by: { $0.rank < $1.rank }) ?? .info + } +} + +private struct PluginFindingsCard: View { + var findings: [PluginFinding] + var addGuardrail: (Guardrail) -> Void + + var body: some View { + if !findings.isEmpty { + GroupBox(L("Plugins")) { + VStack(alignment: .leading, spacing: 8) { + ForEach(findings) { finding in + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Label(finding.title, systemImage: finding.severity.symbol) + .font(.callout.bold()).foregroundStyle(finding.severity.color) + Spacer() + Text(finding.kind.title).font(.caption2.bold()).foregroundStyle(.secondary) + } + Text(finding.summary).font(.caption).fixedSize(horizontal: false, vertical: true) + if !finding.evidence.isEmpty { + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(finding.evidence.enumerated()), id: \.offset) { _, evidence in + Text("\(evidence.label): \(evidence.value)") + .font(.caption2.monospaced()).foregroundStyle(.secondary).lineLimit(3) + } + } + } + HStack(spacing: 6) { + Text(L("%@ · %@", finding.pluginID, finding.pluginVersion)) + .font(.caption2).foregroundStyle(.tertiary).lineLimit(1) + if let guardrail = finding.suggestedGuardrail { + Spacer() + Button(L("Add Guardrail")) { addGuardrail(guardrail) } + .buttonStyle(.link).font(.caption) + .help(L("Save this as a normal Flowlight guardrail")) + } + } + } + .padding(.vertical, 2) + if finding.id != findings.last?.id { Divider() } + } + Text(L("Plugin findings are advisory. They did not block or change this request.")) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(4) + } + } + } +} + +private extension PluginFinding.Severity { + var rank: Int { + switch self { case .info: return 0; case .low: return 1; case .medium: return 2; case .high: return 3 } + } + + var symbol: String { + switch self { + case .info: return "info.circle" + case .low: return "exclamationmark.circle" + case .medium: return "exclamationmark.triangle.fill" + case .high: return "xmark.octagon.fill" + } + } + + var color: Color { + switch self { + case .info: return .secondary + case .low: return FL.warning + case .medium: return .orange + case .high: return FL.critical + } + } +} + /// One request and response: tool calls first, then headers and bodies. private struct ExchangeDetail: View { @EnvironmentObject var monitor: TrafficMonitor let exchange: HTTPExchange var assessment: RiskAssessment? + var pluginFindings: [PluginFinding] = [] var cause: ToolCallLinks.Link? var results: [String: ToolResult] = [:] /// The toolbar's search term, so matches inside a body are marked where they appear. @@ -570,6 +677,7 @@ private struct ExchangeDetail: View { if LocalRiskSettings.isActive { PotentialHarmCard(assessment: assessment) } + PluginFindingsCard(findings: pluginFindings) { monitor.guardrails.save($0) } if let note = exchange.note { Label(note, systemImage: "lock").foregroundStyle(FL.warning).fixedSize(horizontal: false, vertical: true) } diff --git a/Flowlight/UI/PluginSettingsView.swift b/Flowlight/UI/PluginSettingsView.swift new file mode 100644 index 00000000..bd4c7380 --- /dev/null +++ b/Flowlight/UI/PluginSettingsView.swift @@ -0,0 +1,88 @@ +import SwiftUI + +struct PluginSettingsView: View { + @EnvironmentObject var monitor: TrafficMonitor + @State private var showClearConfirmation = false + + 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) + } + 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")) + } + } + .formStyle(.grouped) + .frame(height: 520) + .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.")) + } + } + + private func pluginSection(_ title: String, plugins: [PluginManifest], empty: String) -> some View { + Section(title) { + if plugins.isEmpty { + Text(empty).font(.caption).foregroundStyle(.secondary) + } else { + ForEach(plugins) { plugin in + PluginSettingsRow(plugin: plugin) { enabled in + monitor.plugins.setEnabled(plugin, enabled) + } + } + } + } + } +} + +private struct PluginSettingsRow: View { + var plugin: PluginManifest + var setEnabled: (Bool) -> Void + + 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")) } + } + 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) + } + } + .toggleStyle(.switch) + } +} + +private struct PluginPill: View { + var text: String + init(_ text: String) { self.text = text } + + var body: some View { + Text(text) + .font(.caption2.bold()) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(.quaternary.opacity(0.7), in: Capsule()) + } +} + +private extension PluginManifest.Publisher { + var title: String { + switch self { + case .official: return L("Official") + case .thirdParty: return L("Third-party") + } + } +} diff --git a/Flowlight/UI/SettingsView.swift b/Flowlight/UI/SettingsView.swift index 0c1db697..ba338904 100644 --- a/Flowlight/UI/SettingsView.swift +++ b/Flowlight/UI/SettingsView.swift @@ -108,6 +108,9 @@ struct SettingsView: View { .frame(height: 150) .tabItem { Label(L("Storage"), systemImage: "internaldrive") } + PluginSettingsView() + .tabItem { Label(L("Plugins"), systemImage: "puzzlepiece.extension") } + ExportSettingsTab() .tabItem { Label(L("Export"), systemImage: "arrow.up.forward.square") } } diff --git a/FlowlightTests/PluginTests.swift b/FlowlightTests/PluginTests.swift new file mode 100644 index 00000000..38369643 --- /dev/null +++ b/FlowlightTests/PluginTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import Flowlight + +final class PluginTests: XCTestCase { + private var url: URL! + + override func setUp() { + super.setUp() + url = FileManager.default.temporaryDirectory.appendingPathComponent("plugins-\(UUID()).sqlite") + } + + override func tearDown() { + try? FileManager.default.removeItem(at: url) + super.tearDown() + } + + private func database() throws -> TrafficDatabase { try TrafficDatabase(url: url) } + + private func exchange(method: String = "DELETE", path: String = "/admin/delete?token=secret", requestSize: Int = 700_000, + headers: [HTTPHeader] = [HTTPHeader(name: "Authorization", value: "Bearer secret-token"), + HTTPHeader(name: "X-API-Key", value: "secret-key")], + toolCalls: [ToolCall] = [], llm: LLMFacts? = nil, mcp: [MCPActivity] = []) -> HTTPExchange { + HTTPExchange(id: nil, started: Date(), duration: 0.2, scheme: "https", host: "api.example.test", port: 443, + method: method, path: path, status: 200, requestHeaders: headers, + requestBody: Data("{\"secret\":\"do-not-leak\"}".utf8), requestSize: requestSize, requestTruncated: false, + responseHeaders: [], responseBody: Data(), responseSize: 0, responseTruncated: false, contentType: "application/json", + pid: 1, bundleID: "agent", appName: "Agent", agent: "agent", agentName: "Agent", mcpServer: nil, + toolCalls: toolCalls, toolResults: [], mcp: mcp, llm: llm, note: nil, mockRule: nil, guardrail: nil) + } + + func testBuiltInTrafficPluginsProduceBoundedAdvisoryFindings() { + let findings = PluginEngine.evaluate(exchange(), manifests: PluginEngine.builtInManifests) + XCTAssertTrue(findings.contains { $0.pluginID == "traffic.large-upload" }) + XCTAssertTrue(findings.contains { $0.pluginID == "traffic.risky-method" }) + XCTAssertTrue(findings.contains { $0.pluginID == "traffic.sensitive-headers" }) + let allText = findings.flatMap { [$0.title, $0.summary] + $0.evidence.map(\.value) }.joined(separator: "\n") + XCTAssertFalse(allText.contains("secret-token")) + XCTAssertFalse(allText.contains("secret-key")) + XCTAssertFalse(allText.contains("do-not-leak")) + } + + func testHighRiskToolPluginSuggestsARealGuardrail() throws { + let llm = LLMFacts(provider: .anthropic, model: "claude-test", declaredTools: [DeclaredTool(name: "Bash", kind: .function)], + connectors: [], usage: nil, stopReason: nil, errorType: nil) + let findings = PluginEngine.evaluate(exchange(requestSize: 10, headers: [], llm: llm), manifests: PluginEngine.builtInManifests) + let finding = try XCTUnwrap(findings.first { $0.pluginID == "llm-mcp.high-risk-tools" }) + XCTAssertEqual(finding.kind, .llmMCP) + let guardrail = try XCTUnwrap(finding.suggestedGuardrail) + XCTAssertEqual(guardrail.origin, .observed) + XCTAssertEqual(guardrail.tool, "Bash") + XCTAssertTrue(guardrail.isComplete) + } + + func testDisabledPluginProducesNoNewFinding() throws { + let manifests = PluginEngine.builtInManifests.map { manifest -> PluginManifest in + var copy = manifest + if copy.id == "traffic.large-upload" { copy.enabled = false } + return copy + } + let findings = PluginEngine.evaluate(exchange(), manifests: manifests) + XCTAssertFalse(findings.contains { $0.pluginID == "traffic.large-upload" }) + XCTAssertTrue(findings.contains { $0.pluginID == "traffic.risky-method" }) + } + + func testFindingsPersistHideWhenDisabledAndPruneWithExchange() throws { + let db = try database() + let id = try db.insertExchange(exchange()) + let visible = try db.pluginFindings(exchangeIDs: [id]) + XCTAssertNotNil(visible[id]?.first { $0.pluginID == "traffic.large-upload" }) + + try db.setPluginEnabled(id: "traffic.large-upload", enabled: false) + XCTAssertNil(try db.pluginFindings(exchangeIDs: [id])[id]?.first { $0.pluginID == "traffic.large-upload" }) + XCTAssertNotNil(try db.pluginFindings(exchangeIDs: [id], visibleOnly: false)[id]?.first { $0.pluginID == "traffic.large-upload" }) + + try db.pruneExchanges(olderThan: Date().addingTimeInterval(60)) + XCTAssertTrue(try db.pluginFindings(exchangeIDs: [id], visibleOnly: false).isEmpty) + } + + func testManifestTracksOfficialCommonGuardrailProviders() throws { + var manifest = PluginManifest(id: "official.presidio-pii", name: "Presidio PII", version: "1.0.0", kind: .llmMCP, + guardrailProvider: .presidio, + description: "Detects and masks PII.", privacySummary: "Uses redacted inspection metadata.") + manifest.source = .installed + manifest.publisher = .official + let data = try JSONEncoder().encode(manifest) + let decoded = try JSONDecoder().decode(PluginManifest.self, from: data) + XCTAssertEqual(decoded.publisher, .official) + XCTAssertEqual(decoded.guardrailProvider, .presidio) + XCTAssertEqual(PluginManifest.GuardrailProvider.prismaAIRS.title, "PANW Prisma AIRS") + } +} diff --git a/docs/404.html b/docs/404.html index 0e233050..f4747c3e 100644 --- a/docs/404.html +++ b/docs/404.html @@ -38,6 +38,7 @@