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.
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 @@
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 @@
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 @@
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.
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.
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.
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/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()