diff --git a/Flowlight/Localization/de.lproj/Localizable.strings b/Flowlight/Localization/de.lproj/Localizable.strings index 60d6e23..b304ef8 100644 --- a/Flowlight/Localization/de.lproj/Localizable.strings +++ b/Flowlight/Localization/de.lproj/Localizable.strings @@ -1536,6 +1536,7 @@ "Modify responses" = "Modify responses"; "Add Guardrail" = "Add Guardrail"; "Save this as a normal Flowlight guardrail" = "Save this as a normal Flowlight guardrail"; +"Save this as a normal Flowlight rule" = "Save this as a normal Flowlight rule"; "Plugin findings are advisory. They did not block or change this request." = "Plugin findings are advisory. They did not block or change this request."; "Let the request reach %@, then change its eligible response before this app receives it" = "Let the request reach %@, then change its eligible response before this app receives it"; "%@ answered this request. Flowlight then changed the response with “%@” before the app received it." = "%@ answered this request. Flowlight then changed the response with “%@” before the app received it."; diff --git a/Flowlight/Localization/en.lproj/Localizable.strings b/Flowlight/Localization/en.lproj/Localizable.strings index 169c1d7..874e572 100644 --- a/Flowlight/Localization/en.lproj/Localizable.strings +++ b/Flowlight/Localization/en.lproj/Localizable.strings @@ -1535,6 +1535,7 @@ "Modify responses" = "Modify responses"; "Add Guardrail" = "Add Guardrail"; "Save this as a normal Flowlight guardrail" = "Save this as a normal Flowlight guardrail"; +"Save this as a normal Flowlight rule" = "Save this as a normal Flowlight rule"; "Plugin findings are advisory. They did not block or change this request." = "Plugin findings are advisory. They did not block or change this request."; "Let the request reach %@, then change its eligible response before this app receives it" = "Let the request reach %@, then change its eligible response before this app receives it"; "%@ answered this request. Flowlight then changed the response with “%@” before the app received it." = "%@ answered this request. Flowlight then changed the response with “%@” before the app received it."; diff --git a/Flowlight/Localization/es.lproj/Localizable.strings b/Flowlight/Localization/es.lproj/Localizable.strings index 3d83d68..c4e1dab 100644 --- a/Flowlight/Localization/es.lproj/Localizable.strings +++ b/Flowlight/Localization/es.lproj/Localizable.strings @@ -1536,6 +1536,7 @@ "Modify responses" = "Modify responses"; "Add Guardrail" = "Add Guardrail"; "Save this as a normal Flowlight guardrail" = "Save this as a normal Flowlight guardrail"; +"Save this as a normal Flowlight rule" = "Save this as a normal Flowlight rule"; "Plugin findings are advisory. They did not block or change this request." = "Plugin findings are advisory. They did not block or change this request."; "Let the request reach %@, then change its eligible response before this app receives it" = "Let the request reach %@, then change its eligible response before this app receives it"; "%@ answered this request. Flowlight then changed the response with “%@” before the app received it." = "%@ answered this request. Flowlight then changed the response with “%@” before the app received it."; diff --git a/Flowlight/Localization/fr.lproj/Localizable.strings b/Flowlight/Localization/fr.lproj/Localizable.strings index f564706..f2ebf2e 100644 --- a/Flowlight/Localization/fr.lproj/Localizable.strings +++ b/Flowlight/Localization/fr.lproj/Localizable.strings @@ -1536,6 +1536,7 @@ "Modify responses" = "Modify responses"; "Add Guardrail" = "Add Guardrail"; "Save this as a normal Flowlight guardrail" = "Save this as a normal Flowlight guardrail"; +"Save this as a normal Flowlight rule" = "Save this as a normal Flowlight rule"; "Plugin findings are advisory. They did not block or change this request." = "Plugin findings are advisory. They did not block or change this request."; "Let the request reach %@, then change its eligible response before this app receives it" = "Let the request reach %@, then change its eligible response before this app receives it"; "%@ answered this request. Flowlight then changed the response with “%@” before the app received it." = "%@ answered this request. Flowlight then changed the response with “%@” before the app received it."; diff --git a/Flowlight/Localization/it.lproj/Localizable.strings b/Flowlight/Localization/it.lproj/Localizable.strings index 373cdeb..db41ba4 100644 --- a/Flowlight/Localization/it.lproj/Localizable.strings +++ b/Flowlight/Localization/it.lproj/Localizable.strings @@ -1536,6 +1536,7 @@ "Modify responses" = "Modify responses"; "Add Guardrail" = "Add Guardrail"; "Save this as a normal Flowlight guardrail" = "Save this as a normal Flowlight guardrail"; +"Save this as a normal Flowlight rule" = "Save this as a normal Flowlight rule"; "Plugin findings are advisory. They did not block or change this request." = "Plugin findings are advisory. They did not block or change this request."; "Let the request reach %@, then change its eligible response before this app receives it" = "Let the request reach %@, then change its eligible response before this app receives it"; "%@ answered this request. Flowlight then changed the response with “%@” before the app received it." = "%@ answered this request. Flowlight then changed the response with “%@” before the app received it."; diff --git a/Flowlight/Localization/ja.lproj/Localizable.strings b/Flowlight/Localization/ja.lproj/Localizable.strings index 65883b9..27dced5 100644 --- a/Flowlight/Localization/ja.lproj/Localizable.strings +++ b/Flowlight/Localization/ja.lproj/Localizable.strings @@ -1536,6 +1536,7 @@ "Modify responses" = "Modify responses"; "Add Guardrail" = "Add Guardrail"; "Save this as a normal Flowlight guardrail" = "Save this as a normal Flowlight guardrail"; +"Save this as a normal Flowlight rule" = "Save this as a normal Flowlight rule"; "Plugin findings are advisory. They did not block or change this request." = "Plugin findings are advisory. They did not block or change this request."; "Let the request reach %@, then change its eligible response before this app receives it" = "Let the request reach %@, then change its eligible response before this app receives it"; "%@ answered this request. Flowlight then changed the response with “%@” before the app received it." = "%@ answered this request. Flowlight then changed the response with “%@” before the app received it."; diff --git a/Flowlight/Localization/ko.lproj/Localizable.strings b/Flowlight/Localization/ko.lproj/Localizable.strings index 5bd9d2b..87c21e8 100644 --- a/Flowlight/Localization/ko.lproj/Localizable.strings +++ b/Flowlight/Localization/ko.lproj/Localizable.strings @@ -1535,6 +1535,7 @@ "Modify responses" = "Modify responses"; "Add Guardrail" = "Add Guardrail"; "Save this as a normal Flowlight guardrail" = "Save this as a normal Flowlight guardrail"; +"Save this as a normal Flowlight rule" = "Save this as a normal Flowlight rule"; "Plugin findings are advisory. They did not block or change this request." = "Plugin findings are advisory. They did not block or change this request."; "Let the request reach %@, then change its eligible response before this app receives it" = "Let the request reach %@, then change its eligible response before this app receives it"; "%@ answered this request. Flowlight then changed the response with “%@” before the app received it." = "%@ answered this request. Flowlight then changed the response with “%@” before the app received it."; diff --git a/Flowlight/Localization/pt-PT.lproj/Localizable.strings b/Flowlight/Localization/pt-PT.lproj/Localizable.strings index d187657..e571ecc 100644 --- a/Flowlight/Localization/pt-PT.lproj/Localizable.strings +++ b/Flowlight/Localization/pt-PT.lproj/Localizable.strings @@ -1535,6 +1535,7 @@ "Modify responses" = "Modify responses"; "Add Guardrail" = "Add Guardrail"; "Save this as a normal Flowlight guardrail" = "Save this as a normal Flowlight guardrail"; +"Save this as a normal Flowlight rule" = "Save this as a normal Flowlight rule"; "Plugin findings are advisory. They did not block or change this request." = "Plugin findings are advisory. They did not block or change this request."; "Let the request reach %@, then change its eligible response before this app receives it" = "Let the request reach %@, then change its eligible response before this app receives it"; "%@ answered this request. Flowlight then changed the response with “%@” before the app received it." = "%@ answered this request. Flowlight then changed the response with “%@” before the app received it."; diff --git a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings index e1dad86..13fb98e 100644 --- a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings +++ b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings @@ -1535,6 +1535,7 @@ "Modify responses" = "待翻译:Modify responses"; "Add Guardrail" = "待翻译:Add Guardrail"; "Save this as a normal Flowlight guardrail" = "待翻译:Save this as a normal Flowlight guardrail"; +"Save this as a normal Flowlight rule" = "待翻译:Save this as a normal Flowlight rule"; "Plugin findings are advisory. They did not block or change this request." = "待翻译:Plugin findings are advisory. They did not block or change this request."; "Let the request reach %@, then change its eligible response before this app receives it" = "待翻译:Let the request reach %@, then change its eligible response before this app receives it"; "%@ answered this request. Flowlight then changed the response with “%@” before the app received it." = "待翻译:%@ answered this request. Flowlight then changed the response with “%@” before the app received it."; diff --git a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings index d745d39..5502a6e 100644 --- a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings +++ b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings @@ -1535,6 +1535,7 @@ "Modify responses" = "Modify responses"; "Add Guardrail" = "Add Guardrail"; "Save this as a normal Flowlight guardrail" = "Save this as a normal Flowlight guardrail"; +"Save this as a normal Flowlight rule" = "Save this as a normal Flowlight rule"; "Plugin findings are advisory. They did not block or change this request." = "Plugin findings are advisory. They did not block or change this request."; "Let the request reach %@, then change its eligible response before this app receives it" = "Let the request reach %@, then change its eligible response before this app receives it"; "%@ answered this request. Flowlight then changed the response with “%@” before the app received it." = "%@ answered this request. Flowlight then changed the response with “%@” before the app received it."; diff --git a/Flowlight/Plugins/PluginEngine.swift b/Flowlight/Plugins/PluginEngine.swift index 2a4c7b2..9b27fb8 100644 --- a/Flowlight/Plugins/PluginEngine.swift +++ b/Flowlight/Plugins/PluginEngine.swift @@ -67,9 +67,10 @@ enum PluginEngine { private extension InspectionPluginRule { func finding(_ severity: PluginFinding.Severity, _ title: String, _ summary: String, - _ evidence: [PluginEvidence], suggestedGuardrail: Guardrail? = nil) -> PluginFindingDraft { + _ evidence: [PluginEvidence], suggestedGuardrail: Guardrail? = nil, suggestedRule: Rule? = nil) -> PluginFindingDraft { PluginFindingDraft(pluginID: manifest.id, pluginVersion: manifest.version, kind: manifest.kind, severity: severity, - title: title, summary: summary, evidence: evidence, suggestedGuardrail: suggestedGuardrail) + title: title, summary: summary, evidence: evidence, + suggestedGuardrail: suggestedGuardrail, suggestedRule: suggestedRule) } } diff --git a/Flowlight/Plugins/PluginModels.swift b/Flowlight/Plugins/PluginModels.swift index 61e4030..a06ed82 100644 --- a/Flowlight/Plugins/PluginModels.swift +++ b/Flowlight/Plugins/PluginModels.swift @@ -287,6 +287,7 @@ struct PluginFinding: Codable, Identifiable, Equatable, Sendable { var summary: String var evidence: [PluginEvidence] var suggestedGuardrail: Guardrail? + var suggestedRule: Rule? var createdAt: Date enum Severity: String, Codable, Sendable, CaseIterable { case info, low, medium, high } @@ -301,12 +302,13 @@ struct PluginFindingDraft: Equatable, Sendable { var summary: String var evidence: [PluginEvidence] var suggestedGuardrail: Guardrail? + var suggestedRule: Rule? 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) + suggestedGuardrail: suggestedGuardrail, suggestedRule: suggestedRule, createdAt: createdAt) } } diff --git a/Flowlight/Plugins/PluginScriptRunner.swift b/Flowlight/Plugins/PluginScriptRunner.swift index cf573f1..a30ce1c 100644 --- a/Flowlight/Plugins/PluginScriptRunner.swift +++ b/Flowlight/Plugins/PluginScriptRunner.swift @@ -82,22 +82,44 @@ final class PluginScriptRunner: @unchecked Sendable { !label.isEmpty, !value.isEmpty else { return nil } return PluginEvidence(label, value) } - let guardrail = suggestedGuardrail(from: object["suggestedGuardrail"], exchange: exchange) + let maySuggest = manifest.capabilities.contains(.suggestGuardrail) && manifest.permissions.contains(.suggestGuardrail) + let guardrail = maySuggest ? suggestedGuardrail(from: object["suggestedGuardrail"], exchange: exchange) : nil + let rule = maySuggest ? suggestedRule(from: object["suggestedRule"], exchange: exchange) : nil return PluginFindingDraft(pluginID: manifest.id, pluginVersion: manifest.version, kind: manifest.kind, severity: severity, - title: title, summary: summary, evidence: evidence, suggestedGuardrail: guardrail) + title: title, summary: summary, evidence: evidence, + suggestedGuardrail: guardrail, suggestedRule: rule) } private func suggestedGuardrail(from value: Any?, exchange: HTTPExchange) -> Guardrail? { guard let object = value as? [String: Any] else { return nil } - var guardrail = Guardrail(agent: object["agent"] as? String ?? exchange.agent ?? "", - server: object["server"] as? String ?? "", - tool: object["tool"] as? String ?? "", + var guardrail = Guardrail(agent: limited(object["agent"], fallback: exchange.agent ?? "", count: 120), + server: limited(object["server"], count: 120), + tool: limited(object["tool"], count: 120), origin: .observed) - guardrail.resource = object["resource"] as? String ?? "" - guardrail.name = String((object["name"] as? String ?? "").prefix(120)) + guardrail.resource = limited(object["resource"], count: 240) + guardrail.name = limited(object["name"], count: 120) return guardrail.isComplete ? guardrail : nil } + private func suggestedRule(from value: Any?, exchange: HTTPExchange) -> Rule? { + guard let object = value as? [String: Any] else { return nil } + let action = Rule.Action(rawValue: limited(object["action"], fallback: "block", count: 20)) ?? .block + let destination = limited(object["destination"], fallback: exchange.host, count: 240) + var rule = Rule(action: action, + app: limited(object["app"], fallback: exchange.agent ?? exchange.bundleID, count: 160), + destination: AgentPolicy.normalize(destination) ?? destination.lowercased(), + path: limited(object["path"], count: 240), + method: limited(object["method"], count: 20).uppercased(), + origin: .alert) + if let status = object["status"] as? Int { rule.status = min(max(status, 400), 599) } + rule.name = limited(object["name"], count: 120) + return rule.isComplete ? rule : nil + } + + private func limited(_ value: Any?, fallback: String = "", count: Int = 120) -> String { + String(((value as? String) ?? fallback).trimmingCharacters(in: .whitespacesAndNewlines).prefix(count)) + } + private func manifestPayload(_ manifest: PluginManifest) -> [String: Any] { [ "id": manifest.id, diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift index 5e4aebb..eff225b 100644 --- a/Flowlight/Storage/TrafficDatabase.swift +++ b/Flowlight/Storage/TrafficDatabase.swift @@ -407,6 +407,10 @@ final class TrafficDatabase: @unchecked Sendable { 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); """) + let pluginFindingColumns = Set(try conn.query("PRAGMA table_info(plugin_findings)") { $0.text(1) }) + if !pluginFindingColumns.contains("suggested_rule") { + try conn.execute("ALTER TABLE plugin_findings ADD COLUMN suggested_rule TEXT NOT NULL DEFAULT ''") + } // 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`. let alertColumns = Set(try conn.query("PRAGMA table_info(alerts)") { $0.text(1) }) @@ -1073,12 +1077,18 @@ final class TrafficDatabase: @unchecked Sendable { } else { guardrail = "" } + let rule: String + if let suggested = finding.suggestedRule { + rule = String(decoding: try encoder.encode(suggested), as: UTF8.self) + } else { + rule = "" + } 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 (?,?,?,?,?,?,?,?,?,?,?) + INSERT OR IGNORE INTO plugin_findings (id, exchange_id, plugin_id, plugin_version, kind, severity, title, summary, evidence, suggested_guardrail, suggested_rule, 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)]) + .text(guardrail), .text(rule), .double(finding.createdAt.timeIntervalSince1970)]) } } @@ -1089,7 +1099,7 @@ final class TrafficDatabase: @unchecked Sendable { 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 + SELECT id, exchange_id, plugin_id, plugin_version, kind, severity, title, summary, evidence, suggested_guardrail, suggested_rule, 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 @@ -1105,11 +1115,14 @@ final class TrafficDatabase: @unchecked Sendable { 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)) + let ruleText = row.text(10) + let rule = ruleText.isEmpty ? nil : try? decoder.decode(Rule.self, from: Data(ruleText.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))) + title: row.text(6), summary: row.text(7), evidence: evidence, + suggestedGuardrail: guardrail, suggestedRule: rule, + createdAt: Date(timeIntervalSince1970: row.double(11))) } // MARK: Rule simulation diff --git a/Flowlight/UI/InspectView.swift b/Flowlight/UI/InspectView.swift index 81b83a2..49704ab 100644 --- a/Flowlight/UI/InspectView.swift +++ b/Flowlight/UI/InspectView.swift @@ -569,6 +569,7 @@ private struct PluginFindingBadge: View { private struct PluginFindingsCard: View { var findings: [PluginFinding] var addGuardrail: (Guardrail) -> Void + var addRule: (Rule) -> Void var body: some View { if !findings.isEmpty { @@ -594,12 +595,19 @@ private struct PluginFindingsCard: View { HStack(spacing: 6) { Text(L("%@ · %@", finding.pluginID, finding.pluginVersion)) .font(.caption2).foregroundStyle(.tertiary).lineLimit(1) - if let guardrail = finding.suggestedGuardrail { + if finding.suggestedGuardrail != nil || finding.suggestedRule != nil { Spacer() + } + if let guardrail = finding.suggestedGuardrail { Button(L("Add Guardrail")) { addGuardrail(guardrail) } .buttonStyle(.link).font(.caption) .help(L("Save this as a normal Flowlight guardrail")) } + if let rule = finding.suggestedRule { + Button(L("Add Rule")) { addRule(rule) } + .buttonStyle(.link).font(.caption) + .help(L("Save this as a normal Flowlight rule")) + } } } .padding(.vertical, 2) @@ -677,7 +685,7 @@ private struct ExchangeDetail: View { if LocalRiskSettings.isActive { PotentialHarmCard(assessment: assessment) } - PluginFindingsCard(findings: pluginFindings) { monitor.guardrails.save($0) } + PluginFindingsCard(findings: pluginFindings, addGuardrail: { monitor.guardrails.save($0) }, addRule: { monitor.rules.save($0) }) if let note = exchange.note { Label(note, systemImage: "lock").foregroundStyle(FL.warning).fixedSize(horizontal: false, vertical: true) } diff --git a/FlowlightResponseScriptHelper/main.swift b/FlowlightResponseScriptHelper/main.swift index 7f99730..73a4750 100644 --- a/FlowlightResponseScriptHelper/main.swift +++ b/FlowlightResponseScriptHelper/main.swift @@ -91,6 +91,25 @@ func cleanGuardrail(_ value: Any?) -> [String: String]? { ] } +func cleanRule(_ value: Any?) -> [String: Any]? { + guard let object = value as? [String: Any] else { return nil } + let action = object["action"] as? String ?? "block" + guard action == "block" || action == "allow" else { return nil } + let app = object["app"] as? String ?? "" + let destination = object["destination"] as? String ?? "" + guard !app.isEmpty || !destination.isEmpty else { return nil } + var clean: [String: Any] = [ + "action": action, + "app": String(app.prefix(160)), + "destination": String(destination.prefix(240)), + "path": String((object["path"] as? String ?? "").prefix(240)), + "method": String((object["method"] as? String ?? "").prefix(20)), + "name": String((object["name"] as? String ?? "").prefix(120)), + ] + if let status = object["status"] as? Int { clean["status"] = min(max(status, 400), 599) } + return clean +} + func runPlugin(_ object: [String: Any]) { guard let script = object["script"] as? String, validScript(script), @@ -125,6 +144,9 @@ func runPlugin(_ object: [String: Any]) { if let guardrail = cleanGuardrail(finding["suggestedGuardrail"]) { clean["suggestedGuardrail"] = guardrail } + if let rule = cleanRule(finding["suggestedRule"]) { + clean["suggestedRule"] = rule + } return clean } FileHandle.standardOutput.write(checkedOutput(["findings": findings])) diff --git a/FlowlightTests/PluginTests.swift b/FlowlightTests/PluginTests.swift index ad85f27..3e9a417 100644 --- a/FlowlightTests/PluginTests.swift +++ b/FlowlightTests/PluginTests.swift @@ -184,6 +184,70 @@ final class PluginTests: XCTestCase { XCTAssertFalse(finding.evidence.map(\.value).joined().contains("Authorization")) } + func testJavaScriptEnforcementSuggestionsRequireDeclaredCapability() throws { + let db = try database() + var manifest = PluginManifest(id: "thirdparty.no-suggest", name: "No Suggest", version: "0.1.0", kind: .llmMCP, + source: .installed, publisher: .thirdParty, guardrailProvider: .custom, + description: "JS plugin.", privacySummary: "Uses metadata only.", + capabilities: [.annotate], permissions: [.trafficMetadata, .llmMetadata]) + manifest.script = """ + function evaluate(context) { + return [{ + severity: 'medium', + title: 'Suggestion without capability', + summary: 'This should stay advisory only.', + evidence: [{ label: 'Host', value: context.exchange.host }], + suggestedGuardrail: { agent: 'agent', tool: 'Bash' }, + suggestedRule: { action: 'block', app: 'agent', destination: 'api.example.test' } + }]; + } + """ + try db.savePluginManifest(manifest) + let id = try db.insertExchange(exchange(requestSize: 10, headers: [])) + let finding = try XCTUnwrap(try db.pluginFindings(exchangeIDs: [id])[id]?.first { $0.pluginID == "thirdparty.no-suggest" }) + XCTAssertNil(finding.suggestedGuardrail) + XCTAssertNil(finding.suggestedRule) + } + + func testJavaScriptPluginCanSuggestNormalRuleWhenDeclared() throws { + let db = try database() + var manifest = PluginManifest(id: "thirdparty.rule-suggest", name: "Rule Suggest", version: "0.1.0", kind: .traffic, + source: .installed, publisher: .thirdParty, guardrailProvider: .custom, + description: "JS plugin.", privacySummary: "Uses metadata only.", + capabilities: [.annotate, .suggestGuardrail], permissions: [.trafficMetadata, .appIdentity, .suggestGuardrail]) + manifest.script = """ + function evaluate(context) { + return [{ + severity: 'medium', + title: 'Suggest rule', + summary: 'Suggest a normal Flowlight rule for the inspected endpoint.', + evidence: [{ label: 'Host', value: context.exchange.host }], + suggestedRule: { + action: 'block', + app: context.exchange.agent || context.exchange.bundleID, + destination: context.exchange.host, + path: context.exchange.path, + method: context.exchange.method, + status: 451, + name: 'Review inspected endpoint' + } + }]; + } + """ + try db.savePluginManifest(manifest) + let id = try db.insertExchange(exchange(method: "POST", path: "/v1/messages?token=secret", requestSize: 10, headers: [])) + let finding = try XCTUnwrap(try db.pluginFindings(exchangeIDs: [id])[id]?.first { $0.pluginID == "thirdparty.rule-suggest" }) + let rule = try XCTUnwrap(finding.suggestedRule) + XCTAssertEqual(rule.action, .block) + XCTAssertEqual(rule.app, "agent") + XCTAssertEqual(rule.destination, "api.example.test") + XCTAssertEqual(rule.path, "/v1/messages") + XCTAssertEqual(rule.method, "POST") + XCTAssertEqual(rule.status, 451) + XCTAssertEqual(rule.origin, .alert) + XCTAssertTrue(rule.isComplete) + } + @MainActor func testPluginStoreRejectsReservedBuiltInPackageID() { let store = PluginStore() diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 5b32938..30c29d0 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -1321,7 +1321,7 @@ 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. + 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 or rules for the user to apply, but it does not block, rewrite, mock, or transform traffic by itself. Flowlight reviews a plugin package before installation. The review shows provenance, signature status, permissions, privacy categories, package digest and script digest. Package import fails closed when a signed package is malformed or tampered with; runtime JavaScript still fails open so capture remains complete. @@ -1372,6 +1372,8 @@ Plugins A finding is a bounded record attached to one inspected exchange. Evidence should name facts, not leak content: method, host, redacted path shape, byte counts, header names, model provider, tool names, MCP server names, approval mode and policy labels. Do not store header values, raw bodies, credentials, full tool inputs, prompts, completions or unredacted query strings. + When a manifest declares the suggestGuardrail capability and permission, a finding can also include a suggestedGuardrail or suggestedRule. Flowlight stores those as inert suggestions; the Inspect view saves them only when the user clicks the normal Add action. + { "severity": "medium", "title": "High-risk tool available", @@ -1385,6 +1387,15 @@ Plugins "server": "", "tool": "Bash", "origin": "observed" + }, + "suggestedRule": { + "action": "block", + "app": "claude", + "destination": "api.example.com", + "path": "/v1/messages", + "method": "POST", + "status": 403, + "name": "Review Claude API traffic" } } diff --git a/docs/plugins/index.html b/docs/plugins/index.html index e8f8dd7..7ae4d56 100644 --- a/docs/plugins/index.html +++ b/docs/plugins/index.html @@ -28,7 +28,7 @@ - + @@ -74,7 +74,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.

+

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 or rules for the user to apply, but it does not block, rewrite, mock, or transform traffic by itself.

Flowlight reviews a plugin package before installation. The review shows provenance, signature status, permissions, privacy categories, package digest and script digest. Package import fails closed when a signed package is malformed or tampered with; runtime JavaScript still fails open so capture remains complete.

Manifest

@@ -114,6 +114,7 @@

Provenance and package review

Findings

A JavaScript plugin defines a synchronous evaluate(context) function. Flowlight passes context.manifest and a sanitized context.exchange containing only the metadata families declared in permissions. Scripts run out of process with an empty environment, a 128 KB script limit and a short timeout; failures produce no findings.

A finding is a bounded record attached to one inspected exchange. Evidence should name facts, not leak content: method, host, redacted path shape, byte counts, header names, model provider, tool names, MCP server names, approval mode and policy labels. Do not store header values, raw bodies, credentials, full tool inputs, prompts, completions or unredacted query strings.

+

When a manifest declares the suggestGuardrail capability and permission, a finding can also include a suggestedGuardrail or suggestedRule. Flowlight stores those as inert suggestions; the Inspect view saves them only when the user clicks the normal Add action.

{
   "severity": "medium",
   "title": "High-risk tool available",
@@ -127,6 +128,15 @@ 

Findings

"server": "", "tool": "Bash", "origin": "observed" + }, + "suggestedRule": { + "action": "block", + "app": "claude", + "destination": "api.example.com", + "path": "/v1/messages", + "method": "POST", + "status": 403, + "name": "Review Claude API traffic" } }
diff --git a/docs/sitemap.xml b/docs/sitemap.xml index 7dac548..076d0e9 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -3,7 +3,7 @@ https://flowlight.xinbetween.com/about/2026-09-25 https://flowlight.xinbetween.com/docs/2026-10-07 https://flowlight.xinbetween.com/2026-10-07 - https://flowlight.xinbetween.com/plugins/2026-10-12 + https://flowlight.xinbetween.com/plugins/2026-10-13 https://flowlight.xinbetween.com/privacy/2026-10-07 https://flowlight.xinbetween.com/releases/2026-10-12 https://flowlight.xinbetween.com/threat-model/2026-09-27 diff --git a/site/pages/plugins.html b/site/pages/plugins.html index b325b96..5d8c167 100644 --- a/site/pages/plugins.html +++ b/site/pages/plugins.html @@ -26,7 +26,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.

+

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 or rules for the user to apply, but it does not block, rewrite, mock, or transform traffic by itself.

Flowlight reviews a plugin package before installation. The review shows provenance, signature status, permissions, privacy categories, package digest and script digest. Package import fails closed when a signed package is malformed or tampered with; runtime JavaScript still fails open so capture remains complete.

Manifest

@@ -66,6 +66,7 @@

Provenance and package review

Findings

A JavaScript plugin defines a synchronous evaluate(context) function. Flowlight passes context.manifest and a sanitized context.exchange containing only the metadata families declared in permissions. Scripts run out of process with an empty environment, a 128 KB script limit and a short timeout; failures produce no findings.

A finding is a bounded record attached to one inspected exchange. Evidence should name facts, not leak content: method, host, redacted path shape, byte counts, header names, model provider, tool names, MCP server names, approval mode and policy labels. Do not store header values, raw bodies, credentials, full tool inputs, prompts, completions or unredacted query strings.

+

When a manifest declares the suggestGuardrail capability and permission, a finding can also include a suggestedGuardrail or suggestedRule. Flowlight stores those as inert suggestions; the Inspect view saves them only when the user clicks the normal Add action.

{
   "severity": "medium",
   "title": "High-risk tool available",
@@ -79,6 +80,15 @@ 

Findings

"server": "", "tool": "Bash", "origin": "observed" + }, + "suggestedRule": { + "action": "block", + "app": "claude", + "destination": "api.example.com", + "path": "/v1/messages", + "method": "POST", + "status": 403, + "name": "Review Claude API traffic" } }
diff --git a/templates/inspection-plugin/README.md b/templates/inspection-plugin/README.md index 9fc6aa9..b06dd4d 100644 --- a/templates/inspection-plugin/README.md +++ b/templates/inspection-plugin/README.md @@ -4,7 +4,7 @@ This template describes an advisory Flowlight inspection plugin package. Flowlig ## Trust boundary -Plugins run after an inspected HTTP exchange has been recorded locally. They can add findings and suggest normal Flowlight guardrails, but they do not block, rewrite, mock, transform, upload, or export traffic by themselves. +Plugins run after an inspected HTTP exchange has been recorded locally. They can add findings and suggest normal Flowlight guardrails or rules for the user to apply, but they do not block, rewrite, mock, transform, upload, or export traffic by themselves. Evidence must be bounded and redacted. Do not include raw request or response bodies, prompts, completions, header values, credentials, full tool inputs, or unredacted query strings. @@ -20,7 +20,7 @@ Evidence must be bounded and redacted. Do not include raw request or response bo Define a synchronous `evaluate(context)` function. Flowlight passes `context.manifest` and `context.exchange`; the exchange contains only the sanitized metadata allowed by the plugin's declared permissions. It never contains request bodies, response bodies, header values, prompts, completions or full tool inputs. -Return up to 8 findings. Each finding uses `severity`, `title`, `summary`, bounded `evidence`, and an optional `suggestedGuardrail` with `agent`, `server`, `tool` or `resource`. Scripts run out of process with an empty environment, a 128 KB script limit and a short timeout. Failures produce no findings. +Return up to 8 findings. Each finding uses `severity`, `title`, `summary`, bounded `evidence`, and optional suggestions. `suggestedGuardrail` can name `agent`, `server`, `tool` or `resource`; `suggestedRule` can name a normal Flowlight `block` or `allow` rule with `app`, `destination`, `path`, `method`, `status` and `name`. Suggestions appear as user-reviewed actions and require the `suggestGuardrail` capability and permission. Scripts run out of process with an empty environment, a 128 KB script limit and a short timeout. Failures produce no findings. ## Files diff --git a/templates/inspection-plugin/findings.example.json b/templates/inspection-plugin/findings.example.json index 4301147..a673bb4 100644 --- a/templates/inspection-plugin/findings.example.json +++ b/templates/inspection-plugin/findings.example.json @@ -22,6 +22,15 @@ { "label": "Provider", "value": "Anthropic" }, { "label": "Connector", "value": "github" }, { "label": "Approval", "value": "always" } - ] + ], + "suggestedRule": { + "action": "block", + "app": "claude", + "destination": "api.github.com", + "path": "", + "method": "", + "status": 403, + "name": "Review provider connector traffic" + } } ] diff --git a/templates/inspection-plugin/validate.js b/templates/inspection-plugin/validate.js index 5cb4dc3..8df6b36 100644 --- a/templates/inspection-plugin/validate.js +++ b/templates/inspection-plugin/validate.js @@ -98,6 +98,17 @@ function checkFindings(findings, source = 'findings') { requireCondition((evidence.label || '').length <= 80, `${source} ${index} evidence label is too long`); requireCondition((evidence.value || '').length <= 240, `${source} ${index} evidence value is too long`); }); + if (finding.suggestedRule !== undefined) { + requireCondition(isPlainObject(finding.suggestedRule), `${source} ${index} suggestedRule must be an object`); + requireCondition(['block', 'allow'].includes(finding.suggestedRule.action || 'block'), `${source} ${index} suggestedRule action must be block or allow`); + requireCondition((finding.suggestedRule.app || finding.suggestedRule.destination), `${source} ${index} suggestedRule must name an app or destination`); + for (const key of ['app', 'destination', 'path', 'method', 'name']) { + requireCondition(typeof (finding.suggestedRule[key] || '') === 'string', `${source} ${index} suggestedRule ${key} must be a string`); + } + if (finding.suggestedRule.status !== undefined) { + requireCondition(Number.isInteger(finding.suggestedRule.status) && finding.suggestedRule.status >= 400 && finding.suggestedRule.status <= 599, `${source} ${index} suggestedRule status must be 400-599`); + } + } }); }