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
7 changes: 7 additions & 0 deletions Flowlight/App/TrafficMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down
187 changes: 187 additions & 0 deletions Flowlight/Plugins/PluginEngine.swift
Original file line number Diff line number Diff line change
@@ -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<String>
var responseHeaderNames: Set<String>
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<String>()
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)
}
}
}
140 changes: 140 additions & 0 deletions Flowlight/Plugins/PluginModels.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading