From 4066d3095ff06c8c15d28ebaff151c421c0c17db Mon Sep 17 00:00:00 2001 From: blessdyb Date: Tue, 13 Oct 2026 23:55:33 -0700 Subject: [PATCH] Add reviewed plugin action handoffs Co-Authored-By: Claude Code --- Flowlight/Inspection/RewriteRule.swift | 21 +++ .../Localization/de.lproj/Localizable.strings | 35 +++- .../Localization/en.lproj/Localizable.strings | 35 +++- .../Localization/es.lproj/Localizable.strings | 35 +++- .../Localization/fr.lproj/Localizable.strings | 35 +++- .../Localization/it.lproj/Localizable.strings | 35 +++- .../Localization/ja.lproj/Localizable.strings | 35 +++- .../Localization/ko.lproj/Localizable.strings | 35 +++- .../pt-PT.lproj/Localizable.strings | 35 +++- .../zh-Hans.lproj/Localizable.strings | 35 +++- .../zh-Hant.lproj/Localizable.strings | 35 +++- Flowlight/Plugins/PluginModels.swift | 130 ++++++++++++- Flowlight/Plugins/PluginPackageVerifier.swift | 1 + Flowlight/Plugins/PluginScriptRunner.swift | 139 +++++++++++++- Flowlight/Storage/TrafficDatabase.swift | 18 +- Flowlight/UI/InspectView.swift | 173 ++++++++++++++++-- Flowlight/UI/PluginSettingsView.swift | 8 +- Flowlight/UI/RewriteRulesView.swift | 20 ++ FlowlightResponseScriptHelper/main.swift | 123 ++++++++++++- FlowlightTests/PluginTests.swift | 71 +++++++ docs/llms-full.txt | 33 ++-- docs/plugins/index.html | 51 +++--- site/pages/plugins.html | 51 +++--- templates/inspection-plugin/README.md | 6 +- .../inspection-plugin/findings.example.json | 43 +++-- templates/inspection-plugin/manifest.json | 22 ++- templates/inspection-plugin/plugin.js | 30 ++- templates/inspection-plugin/validate.js | 107 +++++++++-- 28 files changed, 1209 insertions(+), 188 deletions(-) diff --git a/Flowlight/Inspection/RewriteRule.swift b/Flowlight/Inspection/RewriteRule.swift index 054dda30..1be82dbf 100644 --- a/Flowlight/Inspection/RewriteRule.swift +++ b/Flowlight/Inspection/RewriteRule.swift @@ -90,6 +90,27 @@ struct RewriteRule: Codable, Equatable, Identifiable, Sendable { } } +/// A selected recorded exchange plus safe request context for reviewing a request-rewrite suggestion. +struct RewriteRuleDraft: Identifiable, Equatable, Sendable { + var id: UUID { rule.id } + var rule: RewriteRule + var method: String + var url: String + var requestHeaders: [HTTPHeader] + + init(exchange: HTTPExchange, rule: RewriteRule? = nil) { + self.rule = rule ?? RewriteRule(name: "Rewrite \(exchange.method.uppercased()) \(exchange.host)\(Self.pathOnly(exchange.path))", + host: exchange.host, path: Self.pathOnly(exchange.path), method: exchange.method) + method = exchange.method + url = exchange.url + requestHeaders = exchange.requestHeaders + } + + private static func pathOnly(_ path: String) -> String { + path.split(separator: "?", maxSplits: 1).first.map(String.init) ?? path + } +} + // MARK: Matching & applying /// Pure functions over plain data — no sockets — so the whole rewrite is testable without a proxy. diff --git a/Flowlight/Localization/de.lproj/Localizable.strings b/Flowlight/Localization/de.lproj/Localizable.strings index b304ef89..4b83817f 100644 --- a/Flowlight/Localization/de.lproj/Localizable.strings +++ b/Flowlight/Localization/de.lproj/Localizable.strings @@ -1489,7 +1489,6 @@ "App identity" = "App identity"; "LLM metadata" = "LLM metadata"; "MCP metadata" = "MCP metadata"; -"Suggest guardrails" = "Suggest guardrails"; "Metadata only" = "Metadata only"; "Tool names" = "Tool names"; "Guardrail suggestions" = "Guardrail suggestions"; @@ -1535,8 +1534,6 @@ "Modify requests" = "Modify requests"; "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."; @@ -1567,7 +1564,6 @@ "Content-Length, Connection and Transfer-Encoding are written by Flowlight." = "Content-Length, Connection and Transfer-Encoding are written by Flowlight."; "Binary · hexadecimal" = "Binary · hexadecimal"; "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes." = "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes."; -"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves."; "Import Package…" = "Import Package…"; "Backfill Findings" = "Backfill Findings"; "Clear plugin findings…" = "Clear plugin findings…"; @@ -1590,7 +1586,6 @@ "Backfilled plugin findings: %lld findings." = "Backfilled plugin findings: %lld findings."; "Backfill failed: %@" = "Backfill failed: %@"; "JavaScript" = "JavaScript"; -"Suggests guardrails" = "Suggests guardrails"; "%@ · v%@ · rule v%lld" = "%@ · v%@ · rule v%lld"; "Package %@ · Script %@" = "Package %@ · Script %@"; "Configured: %@" = "Configured: %@"; @@ -1605,7 +1600,6 @@ "Package digest" = "Package digest"; "Script digest" = "Script digest"; "Permissions" = "Permissions"; -"Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic."; "Install" = "Install"; "Not declared" = "Not declared"; "Configure %@" = "Configure %@"; @@ -1651,3 +1645,32 @@ "Clear local analyses?" = "Clear local analyses?"; "Clear analyses" = "Clear analyses"; "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place." = "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place."; + +"Suggest reviewed actions" = "Suggest reviewed actions"; +"Review guardrail" = "Review guardrail"; +"Plugin suggested a normal Flowlight guardrail." = "Plugin suggested a normal Flowlight guardrail."; +"Review rule" = "Review rule"; +"Plugin suggested a normal Flowlight rule." = "Plugin suggested a normal Flowlight rule."; +"Plugin suggested a reviewed Flowlight action." = "Plugin suggested a reviewed Flowlight action."; +"Review mock" = "Review mock"; +"Review rewrite" = "Review rewrite"; +"Review transform" = "Review transform"; +"Optional" = "Optional"; +"Any server" = "Any server"; +"Any tool" = "Any tool"; +"Resource" = "Resource"; +"Any resource" = "Any resource"; +"Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself." = "Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself."; +"Review Guardrail" = "Review Guardrail"; +"Review Rule" = "Review Rule"; +"Review Mock" = "Review Mock"; +"Review Rewrite" = "Review Rewrite"; +"Review Transform" = "Review Transform"; +"Open this suggestion as a normal Flowlight guardrail before saving" = "Open this suggestion as a normal Flowlight guardrail before saving"; +"Open this suggestion as a normal Flowlight rule before saving" = "Open this suggestion as a normal Flowlight rule before saving"; +"Open this suggestion in the mock response editor before saving" = "Open this suggestion in the mock response editor before saving"; +"Open this suggestion in the request rewrite editor before saving" = "Open this suggestion in the request rewrite editor before saving"; +"Open this suggestion in the response transform editor before saving" = "Open this suggestion in the response transform editor before saving"; +"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; +"Suggests reviewed actions" = "Suggests reviewed actions"; +"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; diff --git a/Flowlight/Localization/en.lproj/Localizable.strings b/Flowlight/Localization/en.lproj/Localizable.strings index 874e5727..d04095bc 100644 --- a/Flowlight/Localization/en.lproj/Localizable.strings +++ b/Flowlight/Localization/en.lproj/Localizable.strings @@ -1488,7 +1488,6 @@ "App identity" = "App identity"; "LLM metadata" = "LLM metadata"; "MCP metadata" = "MCP metadata"; -"Suggest guardrails" = "Suggest guardrails"; "Metadata only" = "Metadata only"; "Tool names" = "Tool names"; "Guardrail suggestions" = "Guardrail suggestions"; @@ -1534,8 +1533,6 @@ "Modify requests" = "Modify requests"; "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."; @@ -1566,7 +1563,6 @@ "Content-Length, Connection and Transfer-Encoding are written by Flowlight." = "Content-Length, Connection and Transfer-Encoding are written by Flowlight."; "Binary · hexadecimal" = "Binary · hexadecimal"; "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes." = "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes."; -"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves."; "Import Package…" = "Import Package…"; "Backfill Findings" = "Backfill Findings"; "Clear plugin findings…" = "Clear plugin findings…"; @@ -1589,7 +1585,6 @@ "Backfilled plugin findings: %lld findings." = "Backfilled plugin findings: %lld findings."; "Backfill failed: %@" = "Backfill failed: %@"; "JavaScript" = "JavaScript"; -"Suggests guardrails" = "Suggests guardrails"; "%@ · v%@ · rule v%lld" = "%@ · v%@ · rule v%lld"; "Package %@ · Script %@" = "Package %@ · Script %@"; "Configured: %@" = "Configured: %@"; @@ -1604,7 +1599,6 @@ "Package digest" = "Package digest"; "Script digest" = "Script digest"; "Permissions" = "Permissions"; -"Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic."; "Install" = "Install"; "Not declared" = "Not declared"; "Configure %@" = "Configure %@"; @@ -1650,3 +1644,32 @@ "Clear local analyses?" = "Clear local analyses?"; "Clear analyses" = "Clear analyses"; "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place." = "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place."; + +"Suggest reviewed actions" = "Suggest reviewed actions"; +"Review guardrail" = "Review guardrail"; +"Plugin suggested a normal Flowlight guardrail." = "Plugin suggested a normal Flowlight guardrail."; +"Review rule" = "Review rule"; +"Plugin suggested a normal Flowlight rule." = "Plugin suggested a normal Flowlight rule."; +"Plugin suggested a reviewed Flowlight action." = "Plugin suggested a reviewed Flowlight action."; +"Review mock" = "Review mock"; +"Review rewrite" = "Review rewrite"; +"Review transform" = "Review transform"; +"Optional" = "Optional"; +"Any server" = "Any server"; +"Any tool" = "Any tool"; +"Resource" = "Resource"; +"Any resource" = "Any resource"; +"Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself." = "Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself."; +"Review Guardrail" = "Review Guardrail"; +"Review Rule" = "Review Rule"; +"Review Mock" = "Review Mock"; +"Review Rewrite" = "Review Rewrite"; +"Review Transform" = "Review Transform"; +"Open this suggestion as a normal Flowlight guardrail before saving" = "Open this suggestion as a normal Flowlight guardrail before saving"; +"Open this suggestion as a normal Flowlight rule before saving" = "Open this suggestion as a normal Flowlight rule before saving"; +"Open this suggestion in the mock response editor before saving" = "Open this suggestion in the mock response editor before saving"; +"Open this suggestion in the request rewrite editor before saving" = "Open this suggestion in the request rewrite editor before saving"; +"Open this suggestion in the response transform editor before saving" = "Open this suggestion in the response transform editor before saving"; +"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; +"Suggests reviewed actions" = "Suggests reviewed actions"; +"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; diff --git a/Flowlight/Localization/es.lproj/Localizable.strings b/Flowlight/Localization/es.lproj/Localizable.strings index c4e1dab7..3488203c 100644 --- a/Flowlight/Localization/es.lproj/Localizable.strings +++ b/Flowlight/Localization/es.lproj/Localizable.strings @@ -1489,7 +1489,6 @@ "App identity" = "App identity"; "LLM metadata" = "LLM metadata"; "MCP metadata" = "MCP metadata"; -"Suggest guardrails" = "Suggest guardrails"; "Metadata only" = "Metadata only"; "Tool names" = "Tool names"; "Guardrail suggestions" = "Guardrail suggestions"; @@ -1535,8 +1534,6 @@ "Modify requests" = "Modify requests"; "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."; @@ -1567,7 +1564,6 @@ "Content-Length, Connection and Transfer-Encoding are written by Flowlight." = "Content-Length, Connection and Transfer-Encoding are written by Flowlight."; "Binary · hexadecimal" = "Binary · hexadecimal"; "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes." = "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes."; -"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves."; "Import Package…" = "Import Package…"; "Backfill Findings" = "Backfill Findings"; "Clear plugin findings…" = "Clear plugin findings…"; @@ -1590,7 +1586,6 @@ "Backfilled plugin findings: %lld findings." = "Backfilled plugin findings: %lld findings."; "Backfill failed: %@" = "Backfill failed: %@"; "JavaScript" = "JavaScript"; -"Suggests guardrails" = "Suggests guardrails"; "%@ · v%@ · rule v%lld" = "%@ · v%@ · rule v%lld"; "Package %@ · Script %@" = "Package %@ · Script %@"; "Configured: %@" = "Configured: %@"; @@ -1605,7 +1600,6 @@ "Package digest" = "Package digest"; "Script digest" = "Script digest"; "Permissions" = "Permissions"; -"Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic."; "Install" = "Install"; "Not declared" = "Not declared"; "Configure %@" = "Configure %@"; @@ -1651,3 +1645,32 @@ "Clear local analyses?" = "Clear local analyses?"; "Clear analyses" = "Clear analyses"; "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place." = "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place."; + +"Suggest reviewed actions" = "Suggest reviewed actions"; +"Review guardrail" = "Review guardrail"; +"Plugin suggested a normal Flowlight guardrail." = "Plugin suggested a normal Flowlight guardrail."; +"Review rule" = "Review rule"; +"Plugin suggested a normal Flowlight rule." = "Plugin suggested a normal Flowlight rule."; +"Plugin suggested a reviewed Flowlight action." = "Plugin suggested a reviewed Flowlight action."; +"Review mock" = "Review mock"; +"Review rewrite" = "Review rewrite"; +"Review transform" = "Review transform"; +"Optional" = "Optional"; +"Any server" = "Any server"; +"Any tool" = "Any tool"; +"Resource" = "Resource"; +"Any resource" = "Any resource"; +"Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself." = "Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself."; +"Review Guardrail" = "Review Guardrail"; +"Review Rule" = "Review Rule"; +"Review Mock" = "Review Mock"; +"Review Rewrite" = "Review Rewrite"; +"Review Transform" = "Review Transform"; +"Open this suggestion as a normal Flowlight guardrail before saving" = "Open this suggestion as a normal Flowlight guardrail before saving"; +"Open this suggestion as a normal Flowlight rule before saving" = "Open this suggestion as a normal Flowlight rule before saving"; +"Open this suggestion in the mock response editor before saving" = "Open this suggestion in the mock response editor before saving"; +"Open this suggestion in the request rewrite editor before saving" = "Open this suggestion in the request rewrite editor before saving"; +"Open this suggestion in the response transform editor before saving" = "Open this suggestion in the response transform editor before saving"; +"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; +"Suggests reviewed actions" = "Suggests reviewed actions"; +"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; diff --git a/Flowlight/Localization/fr.lproj/Localizable.strings b/Flowlight/Localization/fr.lproj/Localizable.strings index f2ebf2eb..c6c88cd8 100644 --- a/Flowlight/Localization/fr.lproj/Localizable.strings +++ b/Flowlight/Localization/fr.lproj/Localizable.strings @@ -1489,7 +1489,6 @@ "App identity" = "App identity"; "LLM metadata" = "LLM metadata"; "MCP metadata" = "MCP metadata"; -"Suggest guardrails" = "Suggest guardrails"; "Metadata only" = "Metadata only"; "Tool names" = "Tool names"; "Guardrail suggestions" = "Guardrail suggestions"; @@ -1535,8 +1534,6 @@ "Modify requests" = "Modify requests"; "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."; @@ -1567,7 +1564,6 @@ "Content-Length, Connection and Transfer-Encoding are written by Flowlight." = "Content-Length, Connection and Transfer-Encoding are written by Flowlight."; "Binary · hexadecimal" = "Binary · hexadecimal"; "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes." = "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes."; -"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves."; "Import Package…" = "Import Package…"; "Backfill Findings" = "Backfill Findings"; "Clear plugin findings…" = "Clear plugin findings…"; @@ -1590,7 +1586,6 @@ "Backfilled plugin findings: %lld findings." = "Backfilled plugin findings: %lld findings."; "Backfill failed: %@" = "Backfill failed: %@"; "JavaScript" = "JavaScript"; -"Suggests guardrails" = "Suggests guardrails"; "%@ · v%@ · rule v%lld" = "%@ · v%@ · rule v%lld"; "Package %@ · Script %@" = "Package %@ · Script %@"; "Configured: %@" = "Configured: %@"; @@ -1605,7 +1600,6 @@ "Package digest" = "Package digest"; "Script digest" = "Script digest"; "Permissions" = "Permissions"; -"Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic."; "Install" = "Install"; "Not declared" = "Not declared"; "Configure %@" = "Configure %@"; @@ -1651,3 +1645,32 @@ "Clear local analyses?" = "Clear local analyses?"; "Clear analyses" = "Clear analyses"; "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place." = "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place."; + +"Suggest reviewed actions" = "Suggest reviewed actions"; +"Review guardrail" = "Review guardrail"; +"Plugin suggested a normal Flowlight guardrail." = "Plugin suggested a normal Flowlight guardrail."; +"Review rule" = "Review rule"; +"Plugin suggested a normal Flowlight rule." = "Plugin suggested a normal Flowlight rule."; +"Plugin suggested a reviewed Flowlight action." = "Plugin suggested a reviewed Flowlight action."; +"Review mock" = "Review mock"; +"Review rewrite" = "Review rewrite"; +"Review transform" = "Review transform"; +"Optional" = "Optional"; +"Any server" = "Any server"; +"Any tool" = "Any tool"; +"Resource" = "Resource"; +"Any resource" = "Any resource"; +"Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself." = "Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself."; +"Review Guardrail" = "Review Guardrail"; +"Review Rule" = "Review Rule"; +"Review Mock" = "Review Mock"; +"Review Rewrite" = "Review Rewrite"; +"Review Transform" = "Review Transform"; +"Open this suggestion as a normal Flowlight guardrail before saving" = "Open this suggestion as a normal Flowlight guardrail before saving"; +"Open this suggestion as a normal Flowlight rule before saving" = "Open this suggestion as a normal Flowlight rule before saving"; +"Open this suggestion in the mock response editor before saving" = "Open this suggestion in the mock response editor before saving"; +"Open this suggestion in the request rewrite editor before saving" = "Open this suggestion in the request rewrite editor before saving"; +"Open this suggestion in the response transform editor before saving" = "Open this suggestion in the response transform editor before saving"; +"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; +"Suggests reviewed actions" = "Suggests reviewed actions"; +"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; diff --git a/Flowlight/Localization/it.lproj/Localizable.strings b/Flowlight/Localization/it.lproj/Localizable.strings index db41ba4a..8fd4ff70 100644 --- a/Flowlight/Localization/it.lproj/Localizable.strings +++ b/Flowlight/Localization/it.lproj/Localizable.strings @@ -1489,7 +1489,6 @@ "App identity" = "App identity"; "LLM metadata" = "LLM metadata"; "MCP metadata" = "MCP metadata"; -"Suggest guardrails" = "Suggest guardrails"; "Metadata only" = "Metadata only"; "Tool names" = "Tool names"; "Guardrail suggestions" = "Guardrail suggestions"; @@ -1535,8 +1534,6 @@ "Modify requests" = "Modify requests"; "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."; @@ -1567,7 +1564,6 @@ "Content-Length, Connection and Transfer-Encoding are written by Flowlight." = "Content-Length, Connection and Transfer-Encoding are written by Flowlight."; "Binary · hexadecimal" = "Binary · hexadecimal"; "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes." = "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes."; -"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves."; "Import Package…" = "Import Package…"; "Backfill Findings" = "Backfill Findings"; "Clear plugin findings…" = "Clear plugin findings…"; @@ -1590,7 +1586,6 @@ "Backfilled plugin findings: %lld findings." = "Backfilled plugin findings: %lld findings."; "Backfill failed: %@" = "Backfill failed: %@"; "JavaScript" = "JavaScript"; -"Suggests guardrails" = "Suggests guardrails"; "%@ · v%@ · rule v%lld" = "%@ · v%@ · rule v%lld"; "Package %@ · Script %@" = "Package %@ · Script %@"; "Configured: %@" = "Configured: %@"; @@ -1605,7 +1600,6 @@ "Package digest" = "Package digest"; "Script digest" = "Script digest"; "Permissions" = "Permissions"; -"Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic."; "Install" = "Install"; "Not declared" = "Not declared"; "Configure %@" = "Configure %@"; @@ -1651,3 +1645,32 @@ "Clear local analyses?" = "Clear local analyses?"; "Clear analyses" = "Clear analyses"; "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place." = "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place."; + +"Suggest reviewed actions" = "Suggest reviewed actions"; +"Review guardrail" = "Review guardrail"; +"Plugin suggested a normal Flowlight guardrail." = "Plugin suggested a normal Flowlight guardrail."; +"Review rule" = "Review rule"; +"Plugin suggested a normal Flowlight rule." = "Plugin suggested a normal Flowlight rule."; +"Plugin suggested a reviewed Flowlight action." = "Plugin suggested a reviewed Flowlight action."; +"Review mock" = "Review mock"; +"Review rewrite" = "Review rewrite"; +"Review transform" = "Review transform"; +"Optional" = "Optional"; +"Any server" = "Any server"; +"Any tool" = "Any tool"; +"Resource" = "Resource"; +"Any resource" = "Any resource"; +"Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself." = "Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself."; +"Review Guardrail" = "Review Guardrail"; +"Review Rule" = "Review Rule"; +"Review Mock" = "Review Mock"; +"Review Rewrite" = "Review Rewrite"; +"Review Transform" = "Review Transform"; +"Open this suggestion as a normal Flowlight guardrail before saving" = "Open this suggestion as a normal Flowlight guardrail before saving"; +"Open this suggestion as a normal Flowlight rule before saving" = "Open this suggestion as a normal Flowlight rule before saving"; +"Open this suggestion in the mock response editor before saving" = "Open this suggestion in the mock response editor before saving"; +"Open this suggestion in the request rewrite editor before saving" = "Open this suggestion in the request rewrite editor before saving"; +"Open this suggestion in the response transform editor before saving" = "Open this suggestion in the response transform editor before saving"; +"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; +"Suggests reviewed actions" = "Suggests reviewed actions"; +"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; diff --git a/Flowlight/Localization/ja.lproj/Localizable.strings b/Flowlight/Localization/ja.lproj/Localizable.strings index 27dced56..39693ba5 100644 --- a/Flowlight/Localization/ja.lproj/Localizable.strings +++ b/Flowlight/Localization/ja.lproj/Localizable.strings @@ -1489,7 +1489,6 @@ "App identity" = "App identity"; "LLM metadata" = "LLM metadata"; "MCP metadata" = "MCP metadata"; -"Suggest guardrails" = "Suggest guardrails"; "Metadata only" = "Metadata only"; "Tool names" = "Tool names"; "Guardrail suggestions" = "Guardrail suggestions"; @@ -1535,8 +1534,6 @@ "Modify requests" = "Modify requests"; "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."; @@ -1567,7 +1564,6 @@ "Content-Length, Connection and Transfer-Encoding are written by Flowlight." = "Content-Length, Connection and Transfer-Encoding are written by Flowlight."; "Binary · hexadecimal" = "Binary · hexadecimal"; "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes." = "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes."; -"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves."; "Import Package…" = "Import Package…"; "Backfill Findings" = "Backfill Findings"; "Clear plugin findings…" = "Clear plugin findings…"; @@ -1590,7 +1586,6 @@ "Backfilled plugin findings: %lld findings." = "Backfilled plugin findings: %lld findings."; "Backfill failed: %@" = "Backfill failed: %@"; "JavaScript" = "JavaScript"; -"Suggests guardrails" = "Suggests guardrails"; "%@ · v%@ · rule v%lld" = "%@ · v%@ · rule v%lld"; "Package %@ · Script %@" = "Package %@ · Script %@"; "Configured: %@" = "Configured: %@"; @@ -1605,7 +1600,6 @@ "Package digest" = "Package digest"; "Script digest" = "Script digest"; "Permissions" = "Permissions"; -"Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic."; "Install" = "Install"; "Not declared" = "Not declared"; "Configure %@" = "Configure %@"; @@ -1651,3 +1645,32 @@ "Clear local analyses?" = "Clear local analyses?"; "Clear analyses" = "Clear analyses"; "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place." = "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place."; + +"Suggest reviewed actions" = "Suggest reviewed actions"; +"Review guardrail" = "Review guardrail"; +"Plugin suggested a normal Flowlight guardrail." = "Plugin suggested a normal Flowlight guardrail."; +"Review rule" = "Review rule"; +"Plugin suggested a normal Flowlight rule." = "Plugin suggested a normal Flowlight rule."; +"Plugin suggested a reviewed Flowlight action." = "Plugin suggested a reviewed Flowlight action."; +"Review mock" = "Review mock"; +"Review rewrite" = "Review rewrite"; +"Review transform" = "Review transform"; +"Optional" = "Optional"; +"Any server" = "Any server"; +"Any tool" = "Any tool"; +"Resource" = "Resource"; +"Any resource" = "Any resource"; +"Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself." = "Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself."; +"Review Guardrail" = "Review Guardrail"; +"Review Rule" = "Review Rule"; +"Review Mock" = "Review Mock"; +"Review Rewrite" = "Review Rewrite"; +"Review Transform" = "Review Transform"; +"Open this suggestion as a normal Flowlight guardrail before saving" = "Open this suggestion as a normal Flowlight guardrail before saving"; +"Open this suggestion as a normal Flowlight rule before saving" = "Open this suggestion as a normal Flowlight rule before saving"; +"Open this suggestion in the mock response editor before saving" = "Open this suggestion in the mock response editor before saving"; +"Open this suggestion in the request rewrite editor before saving" = "Open this suggestion in the request rewrite editor before saving"; +"Open this suggestion in the response transform editor before saving" = "Open this suggestion in the response transform editor before saving"; +"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; +"Suggests reviewed actions" = "Suggests reviewed actions"; +"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; diff --git a/Flowlight/Localization/ko.lproj/Localizable.strings b/Flowlight/Localization/ko.lproj/Localizable.strings index 87c21e88..4288ccb5 100644 --- a/Flowlight/Localization/ko.lproj/Localizable.strings +++ b/Flowlight/Localization/ko.lproj/Localizable.strings @@ -1488,7 +1488,6 @@ "App identity" = "App identity"; "LLM metadata" = "LLM metadata"; "MCP metadata" = "MCP metadata"; -"Suggest guardrails" = "Suggest guardrails"; "Metadata only" = "Metadata only"; "Tool names" = "Tool names"; "Guardrail suggestions" = "Guardrail suggestions"; @@ -1534,8 +1533,6 @@ "Modify requests" = "Modify requests"; "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."; @@ -1566,7 +1563,6 @@ "Content-Length, Connection and Transfer-Encoding are written by Flowlight." = "Content-Length, Connection and Transfer-Encoding are written by Flowlight."; "Binary · hexadecimal" = "Binary · hexadecimal"; "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes." = "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes."; -"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves."; "Import Package…" = "Import Package…"; "Backfill Findings" = "Backfill Findings"; "Clear plugin findings…" = "Clear plugin findings…"; @@ -1589,7 +1585,6 @@ "Backfilled plugin findings: %lld findings." = "Backfilled plugin findings: %lld findings."; "Backfill failed: %@" = "Backfill failed: %@"; "JavaScript" = "JavaScript"; -"Suggests guardrails" = "Suggests guardrails"; "%@ · v%@ · rule v%lld" = "%@ · v%@ · rule v%lld"; "Package %@ · Script %@" = "Package %@ · Script %@"; "Configured: %@" = "Configured: %@"; @@ -1604,7 +1599,6 @@ "Package digest" = "Package digest"; "Script digest" = "Script digest"; "Permissions" = "Permissions"; -"Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic."; "Install" = "Install"; "Not declared" = "Not declared"; "Configure %@" = "Configure %@"; @@ -1650,3 +1644,32 @@ "Clear local analyses?" = "Clear local analyses?"; "Clear analyses" = "Clear analyses"; "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place." = "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place."; + +"Suggest reviewed actions" = "Suggest reviewed actions"; +"Review guardrail" = "Review guardrail"; +"Plugin suggested a normal Flowlight guardrail." = "Plugin suggested a normal Flowlight guardrail."; +"Review rule" = "Review rule"; +"Plugin suggested a normal Flowlight rule." = "Plugin suggested a normal Flowlight rule."; +"Plugin suggested a reviewed Flowlight action." = "Plugin suggested a reviewed Flowlight action."; +"Review mock" = "Review mock"; +"Review rewrite" = "Review rewrite"; +"Review transform" = "Review transform"; +"Optional" = "Optional"; +"Any server" = "Any server"; +"Any tool" = "Any tool"; +"Resource" = "Resource"; +"Any resource" = "Any resource"; +"Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself." = "Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself."; +"Review Guardrail" = "Review Guardrail"; +"Review Rule" = "Review Rule"; +"Review Mock" = "Review Mock"; +"Review Rewrite" = "Review Rewrite"; +"Review Transform" = "Review Transform"; +"Open this suggestion as a normal Flowlight guardrail before saving" = "Open this suggestion as a normal Flowlight guardrail before saving"; +"Open this suggestion as a normal Flowlight rule before saving" = "Open this suggestion as a normal Flowlight rule before saving"; +"Open this suggestion in the mock response editor before saving" = "Open this suggestion in the mock response editor before saving"; +"Open this suggestion in the request rewrite editor before saving" = "Open this suggestion in the request rewrite editor before saving"; +"Open this suggestion in the response transform editor before saving" = "Open this suggestion in the response transform editor before saving"; +"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; +"Suggests reviewed actions" = "Suggests reviewed actions"; +"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; diff --git a/Flowlight/Localization/pt-PT.lproj/Localizable.strings b/Flowlight/Localization/pt-PT.lproj/Localizable.strings index e571ecce..8e91b70e 100644 --- a/Flowlight/Localization/pt-PT.lproj/Localizable.strings +++ b/Flowlight/Localization/pt-PT.lproj/Localizable.strings @@ -1488,7 +1488,6 @@ "App identity" = "App identity"; "LLM metadata" = "LLM metadata"; "MCP metadata" = "MCP metadata"; -"Suggest guardrails" = "Suggest guardrails"; "Metadata only" = "Metadata only"; "Tool names" = "Tool names"; "Guardrail suggestions" = "Guardrail suggestions"; @@ -1534,8 +1533,6 @@ "Modify requests" = "Modify requests"; "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."; @@ -1566,7 +1563,6 @@ "Content-Length, Connection and Transfer-Encoding are written by Flowlight." = "Content-Length, Connection and Transfer-Encoding are written by Flowlight."; "Binary · hexadecimal" = "Binary · hexadecimal"; "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes." = "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes."; -"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves."; "Import Package…" = "Import Package…"; "Backfill Findings" = "Backfill Findings"; "Clear plugin findings…" = "Clear plugin findings…"; @@ -1589,7 +1585,6 @@ "Backfilled plugin findings: %lld findings." = "Backfilled plugin findings: %lld findings."; "Backfill failed: %@" = "Backfill failed: %@"; "JavaScript" = "JavaScript"; -"Suggests guardrails" = "Suggests guardrails"; "%@ · v%@ · rule v%lld" = "%@ · v%@ · rule v%lld"; "Package %@ · Script %@" = "Package %@ · Script %@"; "Configured: %@" = "Configured: %@"; @@ -1604,7 +1599,6 @@ "Package digest" = "Package digest"; "Script digest" = "Script digest"; "Permissions" = "Permissions"; -"Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic."; "Install" = "Install"; "Not declared" = "Not declared"; "Configure %@" = "Configure %@"; @@ -1650,3 +1644,32 @@ "Clear local analyses?" = "Clear local analyses?"; "Clear analyses" = "Clear analyses"; "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place." = "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place."; + +"Suggest reviewed actions" = "Suggest reviewed actions"; +"Review guardrail" = "Review guardrail"; +"Plugin suggested a normal Flowlight guardrail." = "Plugin suggested a normal Flowlight guardrail."; +"Review rule" = "Review rule"; +"Plugin suggested a normal Flowlight rule." = "Plugin suggested a normal Flowlight rule."; +"Plugin suggested a reviewed Flowlight action." = "Plugin suggested a reviewed Flowlight action."; +"Review mock" = "Review mock"; +"Review rewrite" = "Review rewrite"; +"Review transform" = "Review transform"; +"Optional" = "Optional"; +"Any server" = "Any server"; +"Any tool" = "Any tool"; +"Resource" = "Resource"; +"Any resource" = "Any resource"; +"Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself." = "Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself."; +"Review Guardrail" = "Review Guardrail"; +"Review Rule" = "Review Rule"; +"Review Mock" = "Review Mock"; +"Review Rewrite" = "Review Rewrite"; +"Review Transform" = "Review Transform"; +"Open this suggestion as a normal Flowlight guardrail before saving" = "Open this suggestion as a normal Flowlight guardrail before saving"; +"Open this suggestion as a normal Flowlight rule before saving" = "Open this suggestion as a normal Flowlight rule before saving"; +"Open this suggestion in the mock response editor before saving" = "Open this suggestion in the mock response editor before saving"; +"Open this suggestion in the request rewrite editor before saving" = "Open this suggestion in the request rewrite editor before saving"; +"Open this suggestion in the response transform editor before saving" = "Open this suggestion in the response transform editor before saving"; +"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; +"Suggests reviewed actions" = "Suggests reviewed actions"; +"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; diff --git a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings index 13fb98eb..f8c91553 100644 --- a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings +++ b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings @@ -1488,7 +1488,6 @@ "App identity" = "待翻译:App identity"; "LLM metadata" = "待翻译:LLM metadata"; "MCP metadata" = "待翻译:MCP metadata"; -"Suggest guardrails" = "待翻译:Suggest guardrails"; "Metadata only" = "待翻译:Metadata only"; "Tool names" = "待翻译:Tool names"; "Guardrail suggestions" = "待翻译:Guardrail suggestions"; @@ -1534,8 +1533,6 @@ "Modify requests" = "待翻译:Modify requests"; "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."; @@ -1566,7 +1563,6 @@ "Content-Length, Connection and Transfer-Encoding are written by Flowlight." = "待翻译:Content-Length, Connection and Transfer-Encoding are written by Flowlight."; "Binary · hexadecimal" = "待翻译:Binary · hexadecimal"; "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes." = "待翻译:Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes."; -"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves." = "待翻译:Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves."; "Import Package…" = "待翻译:Import Package…"; "Backfill Findings" = "待翻译:Backfill Findings"; "Clear plugin findings…" = "待翻译:Clear plugin findings…"; @@ -1589,7 +1585,6 @@ "Backfilled plugin findings: %lld findings." = "待翻译:Backfilled plugin findings: %lld findings."; "Backfill failed: %@" = "待翻译:Backfill failed: %@"; "JavaScript" = "JavaScript"; -"Suggests guardrails" = "待翻译:Suggests guardrails"; "%@ · v%@ · rule v%lld" = "待翻译:%@ · v%@ · rule v%lld"; "Package %@ · Script %@" = "待翻译:Package %@ · Script %@"; "Configured: %@" = "待翻译:Configured: %@"; @@ -1604,7 +1599,6 @@ "Package digest" = "待翻译:Package digest"; "Script digest" = "待翻译:Script digest"; "Permissions" = "Permissions"; -"Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic." = "待翻译:Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic."; "Install" = "Install"; "Not declared" = "待翻译:Not declared"; "Configure %@" = "待翻译:Configure %@"; @@ -1650,3 +1644,32 @@ "Clear local analyses?" = "待翻译:Clear local analyses?"; "Clear analyses" = "待翻译:Clear analyses"; "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place." = "待翻译:This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place."; + +"Suggest reviewed actions" = "待翻译:Suggest reviewed actions"; +"Review guardrail" = "待翻译:Review guardrail"; +"Plugin suggested a normal Flowlight guardrail." = "待翻译:Plugin suggested a normal Flowlight guardrail."; +"Review rule" = "待翻译:Review rule"; +"Plugin suggested a normal Flowlight rule." = "待翻译:Plugin suggested a normal Flowlight rule."; +"Plugin suggested a reviewed Flowlight action." = "待翻译:Plugin suggested a reviewed Flowlight action."; +"Review mock" = "待翻译:Review mock"; +"Review rewrite" = "待翻译:Review rewrite"; +"Review transform" = "待翻译:Review transform"; +"Optional" = "Optional"; +"Any server" = "待翻译:Any server"; +"Any tool" = "待翻译:Any tool"; +"Resource" = "Resource"; +"Any resource" = "待翻译:Any resource"; +"Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself." = "待翻译:Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself."; +"Review Guardrail" = "待翻译:Review Guardrail"; +"Review Rule" = "待翻译:Review Rule"; +"Review Mock" = "待翻译:Review Mock"; +"Review Rewrite" = "待翻译:Review Rewrite"; +"Review Transform" = "待翻译:Review Transform"; +"Open this suggestion as a normal Flowlight guardrail before saving" = "待翻译:Open this suggestion as a normal Flowlight guardrail before saving"; +"Open this suggestion as a normal Flowlight rule before saving" = "待翻译:Open this suggestion as a normal Flowlight rule before saving"; +"Open this suggestion in the mock response editor before saving" = "待翻译:Open this suggestion in the mock response editor before saving"; +"Open this suggestion in the request rewrite editor before saving" = "待翻译:Open this suggestion in the request rewrite editor before saving"; +"Open this suggestion in the response transform editor before saving" = "待翻译:Open this suggestion in the response transform editor before saving"; +"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "待翻译:Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; +"Suggests reviewed actions" = "待翻译:Suggests reviewed actions"; +"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "待翻译:Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; diff --git a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings index 5502a6e2..def5fe42 100644 --- a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings +++ b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings @@ -1488,7 +1488,6 @@ "App identity" = "App identity"; "LLM metadata" = "LLM metadata"; "MCP metadata" = "MCP metadata"; -"Suggest guardrails" = "Suggest guardrails"; "Metadata only" = "Metadata only"; "Tool names" = "Tool names"; "Guardrail suggestions" = "Guardrail suggestions"; @@ -1534,8 +1533,6 @@ "Modify requests" = "Modify requests"; "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."; @@ -1566,7 +1563,6 @@ "Content-Length, Connection and Transfer-Encoding are written by Flowlight." = "Content-Length, Connection and Transfer-Encoding are written by Flowlight."; "Binary · hexadecimal" = "Binary · hexadecimal"; "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes." = "Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes."; -"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, or send traffic anywhere by themselves."; "Import Package…" = "Import Package…"; "Backfill Findings" = "Backfill Findings"; "Clear plugin findings…" = "Clear plugin findings…"; @@ -1589,7 +1585,6 @@ "Backfilled plugin findings: %lld findings." = "Backfilled plugin findings: %lld findings."; "Backfill failed: %@" = "Backfill failed: %@"; "JavaScript" = "JavaScript"; -"Suggests guardrails" = "Suggests guardrails"; "%@ · v%@ · rule v%lld" = "%@ · v%@ · rule v%lld"; "Package %@ · Script %@" = "Package %@ · Script %@"; "Configured: %@" = "Configured: %@"; @@ -1604,7 +1599,6 @@ "Package digest" = "Package digest"; "Script digest" = "Script digest"; "Permissions" = "Permissions"; -"Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic."; "Install" = "Install"; "Not declared" = "Not declared"; "Configure %@" = "Configure %@"; @@ -1650,3 +1644,32 @@ "Clear local analyses?" = "Clear local analyses?"; "Clear analyses" = "Clear analyses"; "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place." = "This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place."; + +"Suggest reviewed actions" = "Suggest reviewed actions"; +"Review guardrail" = "Review guardrail"; +"Plugin suggested a normal Flowlight guardrail." = "Plugin suggested a normal Flowlight guardrail."; +"Review rule" = "Review rule"; +"Plugin suggested a normal Flowlight rule." = "Plugin suggested a normal Flowlight rule."; +"Plugin suggested a reviewed Flowlight action." = "Plugin suggested a reviewed Flowlight action."; +"Review mock" = "Review mock"; +"Review rewrite" = "Review rewrite"; +"Review transform" = "Review transform"; +"Optional" = "Optional"; +"Any server" = "Any server"; +"Any tool" = "Any tool"; +"Resource" = "Resource"; +"Any resource" = "Any resource"; +"Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself." = "Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself."; +"Review Guardrail" = "Review Guardrail"; +"Review Rule" = "Review Rule"; +"Review Mock" = "Review Mock"; +"Review Rewrite" = "Review Rewrite"; +"Review Transform" = "Review Transform"; +"Open this suggestion as a normal Flowlight guardrail before saving" = "Open this suggestion as a normal Flowlight guardrail before saving"; +"Open this suggestion as a normal Flowlight rule before saving" = "Open this suggestion as a normal Flowlight rule before saving"; +"Open this suggestion in the mock response editor before saving" = "Open this suggestion in the mock response editor before saving"; +"Open this suggestion in the request rewrite editor before saving" = "Open this suggestion in the request rewrite editor before saving"; +"Open this suggestion in the response transform editor before saving" = "Open this suggestion in the response transform editor before saving"; +"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves."; +"Suggests reviewed actions" = "Suggests reviewed actions"; +"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic."; diff --git a/Flowlight/Plugins/PluginModels.swift b/Flowlight/Plugins/PluginModels.swift index a06ed82e..288a76b1 100644 --- a/Flowlight/Plugins/PluginModels.swift +++ b/Flowlight/Plugins/PluginModels.swift @@ -50,7 +50,7 @@ struct PluginManifest: Codable, Identifiable, Equatable, Sendable { } } } - enum Capability: String, Codable, Sendable { case annotate, suggestGuardrail } + enum Capability: String, Codable, Sendable { case annotate, suggestGuardrail, suggestAction } init(id: String, name: String, version: String, kind: Kind, enabled: Bool = true, source: Source = .builtIn, publisher: Publisher = .official, guardrailProvider: GuardrailProvider = .flowlight, description: String, @@ -113,6 +113,7 @@ enum PluginPermission: String, Codable, CaseIterable, Sendable { case llmMetadata case mcpMetadata case suggestGuardrail + case suggestAction static let defaultMetadata: [PluginPermission] = [.trafficMetadata, .headerNames, .appIdentity, .llmMetadata, .mcpMetadata] @@ -123,7 +124,7 @@ enum PluginPermission: String, Codable, CaseIterable, Sendable { case .appIdentity: return L("App identity") case .llmMetadata: return L("LLM metadata") case .mcpMetadata: return L("MCP metadata") - case .suggestGuardrail: return L("Suggest guardrails") + case .suggestGuardrail, .suggestAction: return L("Suggest reviewed actions") } } } @@ -276,6 +277,76 @@ struct PluginEvidence: Codable, Equatable, Sendable { } } +struct PluginSuggestedAction: Codable, Equatable, Sendable, Identifiable { + enum Kind: String, Codable, Sendable { + case guardrail + case networkRule + case mockResponse + case requestRewrite + case responseTransform + } + + var id: String { "\(kind.rawValue):\(title)" } + var kind: Kind + var title: String + var reason: String + var guardrail: Guardrail? + var networkRule: Rule? + var mock: MockSuggestion? + var rewrite: RewriteSuggestion? + var responseTransform: ResponseTransformSuggestion? + + init(kind: Kind, title: String, reason: String, guardrail: Guardrail? = nil, networkRule: Rule? = nil, + mock: MockSuggestion? = nil, rewrite: RewriteSuggestion? = nil, responseTransform: ResponseTransformSuggestion? = nil) { + self.kind = kind + self.title = title + self.reason = reason + self.guardrail = guardrail + self.networkRule = networkRule + self.mock = mock + self.rewrite = rewrite + self.responseTransform = responseTransform + } + + static func legacy(guardrail: Guardrail) -> PluginSuggestedAction { + PluginSuggestedAction(kind: .guardrail, title: guardrail.name.isEmpty ? L("Review guardrail") : guardrail.name, + reason: L("Plugin suggested a normal Flowlight guardrail."), guardrail: guardrail) + } + + static func legacy(rule: Rule) -> PluginSuggestedAction { + PluginSuggestedAction(kind: .networkRule, title: rule.name.isEmpty ? L("Review rule") : rule.name, + reason: L("Plugin suggested a normal Flowlight rule."), networkRule: rule) + } + + struct MockSuggestion: Codable, Equatable, Sendable { + var name: String = "" + var host: String = "" + var path: String = "" + var method: String = "" + var status: Int = 500 + var delay: Double = 0 + var headers: [HTTPHeader] = [] + var body: String = "" + } + + struct RewriteSuggestion: Codable, Equatable, Sendable { + var name: String = "" + var host: String = "" + var path: String = "" + var method: String = "" + var headers: [HeaderEdit] = [] + var body: [BodyEdit] = [] + } + + struct ResponseTransformSuggestion: Codable, Equatable, Sendable { + var name: String = "" + var host: String = "" + var path: String = "" + var method: String = "" + var script: String = "" + } +} + struct PluginFinding: Codable, Identifiable, Equatable, Sendable { var id: String var exchangeID: Int64 @@ -288,9 +359,60 @@ struct PluginFinding: Codable, Identifiable, Equatable, Sendable { var evidence: [PluginEvidence] var suggestedGuardrail: Guardrail? var suggestedRule: Rule? + var suggestedActions: [PluginSuggestedAction] var createdAt: Date enum Severity: String, Codable, Sendable, CaseIterable { case info, low, medium, high } + + init(id: String, exchangeID: Int64, pluginID: String, pluginVersion: String, kind: PluginManifest.Kind, severity: Severity, + title: String, summary: String, evidence: [PluginEvidence], suggestedGuardrail: Guardrail? = nil, + suggestedRule: Rule? = nil, suggestedActions: [PluginSuggestedAction] = [], createdAt: Date) { + self.id = id + self.exchangeID = exchangeID + self.pluginID = pluginID + self.pluginVersion = pluginVersion + self.kind = kind + self.severity = severity + self.title = title + self.summary = summary + self.evidence = evidence + self.suggestedGuardrail = suggestedGuardrail + self.suggestedRule = suggestedRule + self.suggestedActions = Self.normalizedActions(suggestedActions, guardrail: suggestedGuardrail, rule: suggestedRule) + self.createdAt = createdAt + } + + private enum CodingKeys: String, CodingKey { + case id, exchangeID, pluginID, pluginVersion, kind, severity, title, summary, evidence, suggestedGuardrail, suggestedRule, suggestedActions, createdAt + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let guardrail = try c.decodeIfPresent(Guardrail.self, forKey: .suggestedGuardrail) + let rule = try c.decodeIfPresent(Rule.self, forKey: .suggestedRule) + id = try c.decode(String.self, forKey: .id) + exchangeID = try c.decode(Int64.self, forKey: .exchangeID) + pluginID = try c.decode(String.self, forKey: .pluginID) + pluginVersion = try c.decode(String.self, forKey: .pluginVersion) + kind = try c.decode(PluginManifest.Kind.self, forKey: .kind) + severity = try c.decode(Severity.self, forKey: .severity) + title = try c.decode(String.self, forKey: .title) + summary = try c.decode(String.self, forKey: .summary) + evidence = try c.decode([PluginEvidence].self, forKey: .evidence) + suggestedGuardrail = guardrail + suggestedRule = rule + suggestedActions = Self.normalizedActions(try c.decodeIfPresent([PluginSuggestedAction].self, forKey: .suggestedActions) ?? [], + guardrail: guardrail, rule: rule) + createdAt = try c.decode(Date.self, forKey: .createdAt) + } + + private static func normalizedActions(_ actions: [PluginSuggestedAction], guardrail: Guardrail?, rule: Rule?) -> [PluginSuggestedAction] { + if !actions.isEmpty { return actions } + var legacy: [PluginSuggestedAction] = [] + if let guardrail { legacy.append(.legacy(guardrail: guardrail)) } + if let rule { legacy.append(.legacy(rule: rule)) } + return legacy + } } struct PluginFindingDraft: Equatable, Sendable { @@ -303,12 +425,14 @@ struct PluginFindingDraft: Equatable, Sendable { var evidence: [PluginEvidence] var suggestedGuardrail: Guardrail? var suggestedRule: Rule? + var suggestedActions: [PluginSuggestedAction] = [] 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, suggestedRule: suggestedRule, createdAt: createdAt) + suggestedGuardrail: suggestedGuardrail, suggestedRule: suggestedRule, + suggestedActions: suggestedActions, createdAt: createdAt) } } diff --git a/Flowlight/Plugins/PluginPackageVerifier.swift b/Flowlight/Plugins/PluginPackageVerifier.swift index aea134d5..6f4e0924 100644 --- a/Flowlight/Plugins/PluginPackageVerifier.swift +++ b/Flowlight/Plugins/PluginPackageVerifier.swift @@ -121,6 +121,7 @@ enum PluginPackageVerifier { private static func normalizedPermissions(_ manifest: PluginManifest) -> [PluginPermission] { var set = Set(manifest.permissions.isEmpty ? PluginPermission.defaultMetadata : manifest.permissions) + if manifest.capabilities.contains(.suggestAction) { set.insert(.suggestAction) } if manifest.capabilities.contains(.suggestGuardrail) { set.insert(.suggestGuardrail) } return PluginPermission.allCases.filter { set.contains($0) } } diff --git a/Flowlight/Plugins/PluginScriptRunner.swift b/Flowlight/Plugins/PluginScriptRunner.swift index a30ce1c7..73a1b60e 100644 --- a/Flowlight/Plugins/PluginScriptRunner.swift +++ b/Flowlight/Plugins/PluginScriptRunner.swift @@ -82,12 +82,55 @@ final class PluginScriptRunner: @unchecked Sendable { !label.isEmpty, !value.isEmpty else { return nil } return PluginEvidence(label, value) } - 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 + let maySuggest = canSuggestActions(manifest) + let actions = maySuggest ? suggestedActions(from: object["suggestedActions"], exchange: exchange) : [] + let legacyGuardrail = maySuggest ? suggestedGuardrail(from: object["suggestedGuardrail"], exchange: exchange) : nil + let legacyRule = maySuggest ? suggestedRule(from: object["suggestedRule"], exchange: exchange) : nil + let normalizedActions = actions.isEmpty ? legacyActions(guardrail: legacyGuardrail, rule: legacyRule) : actions + let guardrail = legacyGuardrail ?? normalizedActions.compactMap(\.guardrail).first + let rule = legacyRule ?? normalizedActions.compactMap(\.networkRule).first return PluginFindingDraft(pluginID: manifest.id, pluginVersion: manifest.version, kind: manifest.kind, severity: severity, title: title, summary: summary, evidence: evidence, - suggestedGuardrail: guardrail, suggestedRule: rule) + suggestedGuardrail: guardrail, suggestedRule: rule, suggestedActions: normalizedActions) + } + + private func canSuggestActions(_ manifest: PluginManifest) -> Bool { + (manifest.capabilities.contains(.suggestAction) || manifest.capabilities.contains(.suggestGuardrail)) && + (manifest.permissions.contains(.suggestAction) || manifest.permissions.contains(.suggestGuardrail)) + } + + private func legacyActions(guardrail: Guardrail?, rule: Rule?) -> [PluginSuggestedAction] { + var actions: [PluginSuggestedAction] = [] + if let guardrail { actions.append(.legacy(guardrail: guardrail)) } + if let rule { actions.append(.legacy(rule: rule)) } + return actions + } + + private func suggestedActions(from value: Any?, exchange: HTTPExchange) -> [PluginSuggestedAction] { + guard let raw = value as? [[String: Any]] else { return [] } + return raw.prefix(4).compactMap { object in + guard let kindText = object["kind"] as? String, + let kind = PluginSuggestedAction.Kind(rawValue: kindText) else { return nil } + let title = limited(object["title"], fallback: defaultActionTitle(kind), count: 120) + let reason = limited(object["reason"], fallback: L("Plugin suggested a reviewed Flowlight action."), count: 240) + switch kind { + case .guardrail: + guard let guardrail = suggestedGuardrail(from: object["guardrail"], exchange: exchange) else { return nil } + return PluginSuggestedAction(kind: kind, title: title, reason: reason, guardrail: guardrail) + case .networkRule: + guard let rule = suggestedRule(from: object["networkRule"], exchange: exchange) else { return nil } + return PluginSuggestedAction(kind: kind, title: title, reason: reason, networkRule: rule) + case .mockResponse: + guard let mock = suggestedMock(from: object["mock"], exchange: exchange) else { return nil } + return PluginSuggestedAction(kind: kind, title: title, reason: reason, mock: mock) + case .requestRewrite: + guard let rewrite = suggestedRewrite(from: object["rewrite"], exchange: exchange) else { return nil } + return PluginSuggestedAction(kind: kind, title: title, reason: reason, rewrite: rewrite) + case .responseTransform: + guard let transform = suggestedResponseTransform(from: object["responseTransform"], exchange: exchange) else { return nil } + return PluginSuggestedAction(kind: kind, title: title, reason: reason, responseTransform: transform) + } + } } private func suggestedGuardrail(from value: Any?, exchange: HTTPExchange) -> Guardrail? { @@ -116,6 +159,94 @@ final class PluginScriptRunner: @unchecked Sendable { return rule.isComplete ? rule : nil } + private func suggestedMock(from value: Any?, exchange: HTTPExchange) -> PluginSuggestedAction.MockSuggestion? { + guard let object = value as? [String: Any] else { return nil } + var suggestion = PluginSuggestedAction.MockSuggestion() + suggestion.name = limited(object["name"], count: 120) + suggestion.host = limited(object["host"], fallback: exchange.host, count: 240).lowercased() + suggestion.path = limited(object["path"], fallback: pathOnly(exchange.path), count: 240) + suggestion.method = limited(object["method"], fallback: exchange.method, count: 20).uppercased() + suggestion.status = clampedInt(object["status"], fallback: exchange.status ?? 500, range: 100...599) + suggestion.delay = clampedDouble(object["delay"], fallback: 0, range: 0...300) + suggestion.headers = suggestedHeaders(from: object["headers"]) + suggestion.body = limited(object["body"], count: 4096) + return suggestion.host.isEmpty ? nil : suggestion + } + + private func suggestedRewrite(from value: Any?, exchange: HTTPExchange) -> PluginSuggestedAction.RewriteSuggestion? { + guard let object = value as? [String: Any] else { return nil } + let headers = suggestedHeaderEdits(from: object["headers"]) + let body = suggestedBodyEdits(from: object["body"]) + guard !headers.isEmpty || !body.isEmpty else { return nil } + return PluginSuggestedAction.RewriteSuggestion(name: limited(object["name"], count: 120), + host: limited(object["host"], fallback: exchange.host, count: 240).lowercased(), + path: limited(object["path"], fallback: pathOnly(exchange.path), count: 240), + method: limited(object["method"], fallback: exchange.method, count: 20).uppercased(), + headers: headers, body: body) + } + + private func suggestedResponseTransform(from value: Any?, exchange: HTTPExchange) -> PluginSuggestedAction.ResponseTransformSuggestion? { + guard let object = value as? [String: Any] else { return nil } + let script = limited(object["script"], count: 4096) + guard !script.isEmpty else { return nil } + return PluginSuggestedAction.ResponseTransformSuggestion(name: limited(object["name"], count: 120), + host: limited(object["host"], fallback: exchange.host, count: 240).lowercased(), + path: limited(object["path"], fallback: pathOnly(exchange.path), count: 240), + method: limited(object["method"], fallback: exchange.method, count: 20).uppercased(), + script: script) + } + + private func suggestedHeaders(from value: Any?) -> [HTTPHeader] { + let raw = (value as? [[String: Any]]) ?? [] + return raw.prefix(16).compactMap { object in + let name = MockRule.headerSafe(limited(object["name"], count: 120)) + guard !name.isEmpty else { return nil } + return HTTPHeader(name: name, value: MockRule.headerSafe(limited(object["value"], count: 240))) + } + } + + private func suggestedHeaderEdits(from value: Any?) -> [HeaderEdit] { + let raw = (value as? [[String: Any]]) ?? [] + return raw.prefix(16).compactMap { object in + let name = MockRule.headerSafe(limited(object["name"], count: 120)) + guard !name.isEmpty else { return nil } + let op = HeaderEdit.Op(rawValue: limited(object["op"], fallback: "set", count: 20)) ?? .set + return HeaderEdit(op: op, name: name, value: MockRule.headerSafe(limited(object["value"], count: 240))) + } + } + + private func suggestedBodyEdits(from value: Any?) -> [BodyEdit] { + let raw = (value as? [[String: Any]]) ?? [] + return raw.prefix(16).compactMap { object in + let path = limited(object["path"], count: 160) + guard !path.isEmpty else { return nil } + let op = BodyEdit.Op(rawValue: limited(object["op"], fallback: "set", count: 20)) ?? .set + return BodyEdit(op: op, path: path, value: limited(object["value"], count: 4096)) + } + } + + private func defaultActionTitle(_ kind: PluginSuggestedAction.Kind) -> String { + switch kind { + case .guardrail: return L("Review guardrail") + case .networkRule: return L("Review rule") + case .mockResponse: return L("Review mock") + case .requestRewrite: return L("Review rewrite") + case .responseTransform: return L("Review transform") + } + } + + private func pathOnly(_ path: String) -> String { + path.split(separator: "?", maxSplits: 1).first.map(String.init) ?? path + } + + private func clampedInt(_ value: Any?, fallback: Int, range: ClosedRange) -> Int { + min(max((value as? Int) ?? fallback, range.lowerBound), range.upperBound) + } + + private func clampedDouble(_ value: Any?, fallback: Double, range: ClosedRange) -> Double { + min(max((value as? Double) ?? fallback, range.lowerBound), range.upperBound) + } + private func limited(_ value: Any?, fallback: String = "", count: Int = 120) -> String { String(((value as? String) ?? fallback).trimmingCharacters(in: .whitespacesAndNewlines).prefix(count)) } diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift index eff225be..c71552a8 100644 --- a/Flowlight/Storage/TrafficDatabase.swift +++ b/Flowlight/Storage/TrafficDatabase.swift @@ -411,6 +411,9 @@ final class TrafficDatabase: @unchecked Sendable { if !pluginFindingColumns.contains("suggested_rule") { try conn.execute("ALTER TABLE plugin_findings ADD COLUMN suggested_rule TEXT NOT NULL DEFAULT ''") } + if !pluginFindingColumns.contains("suggested_actions") { + try conn.execute("ALTER TABLE plugin_findings ADD COLUMN suggested_actions 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) }) @@ -1083,12 +1086,13 @@ final class TrafficDatabase: @unchecked Sendable { } else { rule = "" } + let actions = finding.suggestedActions.isEmpty ? "" : String(decoding: try encoder.encode(finding.suggestedActions), as: UTF8.self) try conn.run(""" - 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 (?,?,?,?,?,?,?,?,?,?,?,?) + INSERT OR IGNORE INTO plugin_findings (id, exchange_id, plugin_id, plugin_version, kind, severity, title, summary, evidence, suggested_guardrail, suggested_rule, suggested_actions, 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), .text(rule), .double(finding.createdAt.timeIntervalSince1970)]) + .text(guardrail), .text(rule), .text(actions), .double(finding.createdAt.timeIntervalSince1970)]) } } @@ -1099,7 +1103,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, suggested_rule, created_at + SELECT id, exchange_id, plugin_id, plugin_version, kind, severity, title, summary, evidence, suggested_guardrail, suggested_rule, suggested_actions, 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 @@ -1117,12 +1121,14 @@ final class TrafficDatabase: @unchecked Sendable { 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)) + let actionsText = row.text(11) + let actions = actionsText.isEmpty ? [] : ((try? decoder.decode([PluginSuggestedAction].self, from: Data(actionsText.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, suggestedRule: rule, - createdAt: Date(timeIntervalSince1970: row.double(11))) + suggestedGuardrail: guardrail, suggestedRule: rule, suggestedActions: actions, + createdAt: Date(timeIntervalSince1970: row.double(12))) } // MARK: Rule simulation diff --git a/Flowlight/UI/InspectView.swift b/Flowlight/UI/InspectView.swift index 49704ab7..62b99e5f 100644 --- a/Flowlight/UI/InspectView.swift +++ b/Flowlight/UI/InspectView.swift @@ -28,8 +28,14 @@ private struct InspectContent: View { @State private var pluginFindings: [Int64: [PluginFinding]] = [:] @State private var window: AgentWindow = .day @State private var showSetup = false + /// Plugin-originated guardrails are reviewed before they become settings. + @State private var guardrailReview: GuardrailReview? + /// Plugin-originated network rules are reviewed in the normal rule editor. + @State private var ruleReview: RuleReview? /// A rule plus safe, temporary captured context waiting in the mock editor. @State private var mockDraft: MockRuleDraft? + /// A rewrite rule plus safe, temporary captured context waiting in the rewrite editor. + @State private var rewriteDraft: RewriteRuleDraft? /// A response-transform rule is created only after its selected exchange's stored response has been read. @State private var responseTransformDraft: ResponseTransformDraft? /// Setup was opened to look at the mock rules, so it opens on them. @@ -98,9 +104,18 @@ private struct InspectContent: View { // applied then, or a person would see an unexplained empty request list. if !LocalRiskSettings.isActive { potentialHarmOnly = false } } + .sheet(item: $guardrailReview) { review in + GuardrailReviewSheet(review: review) { monitor.guardrails.save($0) } + } + .sheet(item: $ruleReview) { review in + RuleEditor(rule: review.rule, isNew: true) { monitor.rules.save($0) } + } .sheet(item: $mockDraft) { draft in MockRuleEditor(rule: draft.rule, isNew: true, draft: draft) { inspection.mockRules.append($0) } } + .sheet(item: $rewriteDraft) { draft in + RewriteRuleEditor(rule: draft.rule, isNew: true, draft: draft) { inspection.rewriteRules.append($0) } + } .sheet(item: $responseTransformDraft) { draft in ResponseTransformRuleEditor(rule: draft.rule, isNew: true, draft: draft) { inspection.responseTransformRules.append($0) } } @@ -146,6 +161,51 @@ private struct InspectContent: View { } } + private func reviewPluginAction(_ action: PluginSuggestedAction, for exchange: HTTPExchange) { + switch action.kind { + case .guardrail: + guard let guardrail = action.guardrail else { return } + guardrailReview = GuardrailReview(action: action, guardrail: guardrail) + case .networkRule: + guard let rule = action.networkRule else { return } + ruleReview = RuleReview(rule: rule) + case .mockResponse: + guard let suggestion = action.mock else { return } + Task { + let bodies: (request: Data, response: Data)? + if let id = exchange.id { + bodies = try? await monitor.read { try $0.exchangeBodies(id: id) } + } else { + bodies = nil + } + var draft = MockRuleDraft(exchange: exchange, requestBody: bodies?.request, responseBody: bodies?.response) + draft.rule.apply(suggestion) + mockDraft = draft + } + case .requestRewrite: + guard let suggestion = action.rewrite else { return } + var rule = RewriteRule(name: suggestion.name, host: suggestion.host, path: suggestion.path, method: suggestion.method, + headers: suggestion.headers, body: suggestion.body) + if rule.host.isEmpty { rule.host = exchange.host } + if rule.path.isEmpty { rule.path = exchange.path.split(separator: "?", maxSplits: 1).first.map(String.init) ?? "/" } + if rule.method.isEmpty { rule.method = exchange.method } + rewriteDraft = RewriteRuleDraft(exchange: exchange, rule: rule) + case .responseTransform: + guard let suggestion = action.responseTransform else { return } + Task { + let body: Data? + if let id = exchange.id { + body = try? await monitor.read { try $0.exchangeBodies(id: id)?.response } + } else { + body = nil + } + var draft = ResponseTransformDraft(exchange: exchange, responseBody: body) + draft.rule.apply(suggestion) + responseTransformDraft = draft + } + } + } + private func load() async { let since = Date().addingTimeInterval(-window.interval), term = search, scope = focus.scope let app = scopeApp?.id, host = scopeHost @@ -342,7 +402,8 @@ private struct InspectContent: View { pluginFindings: selected.id.flatMap { pluginFindings[$0] } ?? [], cause: selected.id.flatMap { links[$0] }, results: results, highlight: search, mockThis: DemoData.isEnabled ? nil : { prepareMock(from: selected) }, - modifyResponse: canModifyResponse(selected) ? { prepareResponseTransform(from: selected) } : nil) + modifyResponse: canModifyResponse(selected) ? { prepareResponseTransform(from: selected) } : nil, + reviewPluginAction: { reviewPluginAction($0, for: selected) }) .id(selected.id) } else { ContentUnavailableView(L("Select a request"), systemImage: "doc.text.magnifyingglass") @@ -546,6 +607,74 @@ private struct InspectionSetup: View { } } +private struct GuardrailReview: Identifiable { + var id = UUID() + var action: PluginSuggestedAction + var guardrail: Guardrail +} + +private struct RuleReview: Identifiable { + var id = UUID() + var rule: Rule +} + +private extension MockRule { + mutating func apply(_ suggestion: PluginSuggestedAction.MockSuggestion) { + if !suggestion.name.isEmpty { name = suggestion.name } + if !suggestion.host.isEmpty { host = suggestion.host } + if !suggestion.path.isEmpty { path = suggestion.path } + if !suggestion.method.isEmpty { method = suggestion.method } + status = suggestion.status + delay = suggestion.delay + if !suggestion.headers.isEmpty { headers = suggestion.headers } + if !suggestion.body.isEmpty { + body = suggestion.body + bodyEncoding = .text + } + } +} + +private extension ResponseTransformRule { + mutating func apply(_ suggestion: PluginSuggestedAction.ResponseTransformSuggestion) { + if !suggestion.name.isEmpty { name = suggestion.name } + if !suggestion.host.isEmpty { host = suggestion.host } + if !suggestion.path.isEmpty { path = suggestion.path } + if !suggestion.method.isEmpty { method = suggestion.method } + if !suggestion.script.isEmpty { script = suggestion.script } + } +} + +private struct GuardrailReviewSheet: View { + @State var review: GuardrailReview + var save: (Guardrail) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(L("Review guardrail")).font(.title3.bold()) + Text(review.action.reason).font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 10, verticalSpacing: 8) { + GridRow { Text(L("Name")).gridColumnAlignment(.trailing).foregroundStyle(.secondary); TextField(L("Optional"), text: $review.guardrail.name) } + GridRow { Text(L("Agent")).gridColumnAlignment(.trailing).foregroundStyle(.secondary); TextField(L("Any agent"), text: $review.guardrail.agent) } + GridRow { Text(L("Server")).gridColumnAlignment(.trailing).foregroundStyle(.secondary); TextField(L("Any server"), text: $review.guardrail.server) } + GridRow { Text(L("Tool")).gridColumnAlignment(.trailing).foregroundStyle(.secondary); TextField(L("Any tool"), text: $review.guardrail.tool) } + GridRow { Text(L("Resource")).gridColumnAlignment(.trailing).foregroundStyle(.secondary); TextField(L("Any resource"), text: $review.guardrail.resource) } + } + Label(L("Saving creates a normal Flowlight guardrail. The plugin cannot save or enforce it by itself."), systemImage: "info.circle") + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + HStack { + Button(L("Cancel"), role: .cancel) { dismiss() }.keyboardShortcut(.cancelAction) + Spacer() + Button(L("Add Guardrail")) { save(review.guardrail); dismiss() } + .keyboardShortcut(.defaultAction) + .disabled(!review.guardrail.isComplete) + } + } + .padding(20) + .frame(width: 520) + } +} + private struct PluginFindingBadge: View { var findings: [PluginFinding] @@ -568,8 +697,7 @@ private struct PluginFindingBadge: View { private struct PluginFindingsCard: View { var findings: [PluginFinding] - var addGuardrail: (Guardrail) -> Void - var addRule: (Rule) -> Void + var reviewAction: (PluginSuggestedAction) -> Void var body: some View { if !findings.isEmpty { @@ -595,18 +723,13 @@ private struct PluginFindingsCard: View { HStack(spacing: 6) { Text(L("%@ · %@", finding.pluginID, finding.pluginVersion)) .font(.caption2).foregroundStyle(.tertiary).lineLimit(1) - if finding.suggestedGuardrail != nil || finding.suggestedRule != nil { + if !finding.suggestedActions.isEmpty { Spacer() } - if let guardrail = finding.suggestedGuardrail { - Button(L("Add Guardrail")) { addGuardrail(guardrail) } + ForEach(finding.suggestedActions) { action in + Button(action.buttonTitle) { reviewAction(action) } .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")) + .help(action.helpText) } } } @@ -623,6 +746,28 @@ private struct PluginFindingsCard: View { } } +private extension PluginSuggestedAction { + var buttonTitle: String { + switch kind { + case .guardrail: return L("Review Guardrail") + case .networkRule: return L("Review Rule") + case .mockResponse: return L("Review Mock") + case .requestRewrite: return L("Review Rewrite") + case .responseTransform: return L("Review Transform") + } + } + + var helpText: String { + switch kind { + case .guardrail: return L("Open this suggestion as a normal Flowlight guardrail before saving") + case .networkRule: return L("Open this suggestion as a normal Flowlight rule before saving") + case .mockResponse: return L("Open this suggestion in the mock response editor before saving") + case .requestRewrite: return L("Open this suggestion in the request rewrite editor before saving") + case .responseTransform: return L("Open this suggestion in the response transform editor before saving") + } + } +} + private extension PluginFinding.Severity { var rank: Int { switch self { case .info: return 0; case .low: return 1; case .medium: return 2; case .high: return 3 } @@ -661,6 +806,8 @@ private struct ExchangeDetail: View { var mockThis: (() -> Void)? /// Starts a response transform from this real upstream exchange. var modifyResponse: (() -> Void)? + /// Opens a normal Flowlight editor for a plugin-suggested action. + var reviewPluginAction: ((PluginSuggestedAction) -> Void)? @State private var bodies: (request: Data, response: Data)? @State private var tab = 0 @State private var headersOpen: Bool? @@ -685,7 +832,7 @@ private struct ExchangeDetail: View { if LocalRiskSettings.isActive { PotentialHarmCard(assessment: assessment) } - PluginFindingsCard(findings: pluginFindings, addGuardrail: { monitor.guardrails.save($0) }, addRule: { monitor.rules.save($0) }) + PluginFindingsCard(findings: pluginFindings, reviewAction: { action in reviewPluginAction?(action) }) if let note = exchange.note { Label(note, systemImage: "lock").foregroundStyle(FL.warning).fixedSize(horizontal: false, vertical: true) } diff --git a/Flowlight/UI/PluginSettingsView.swift b/Flowlight/UI/PluginSettingsView.swift index c5c7bf8a..4c97d635 100644 --- a/Flowlight/UI/PluginSettingsView.swift +++ b/Flowlight/UI/PluginSettingsView.swift @@ -13,7 +13,7 @@ struct PluginSettingsView: View { 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.")) + Text(L("Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves.")) .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) HStack(spacing: 8) { Button(L("Import Package…"), action: importPackage) @@ -156,7 +156,9 @@ private struct PluginSettingsRow: View { PluginPill(plugin.source.title) PluginPill(plugin.provenance.reviewStatus.title) if !plugin.script.isEmpty { PluginPill(L("JavaScript")) } - if plugin.capabilities.contains(.suggestGuardrail) { PluginPill(L("Suggests guardrails")) } + if plugin.capabilities.contains(.suggestAction) || plugin.capabilities.contains(.suggestGuardrail) { + PluginPill(L("Suggests reviewed actions")) + } } Text(plugin.description).font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) Text(plugin.privacySummary).font(.caption2).foregroundStyle(.tertiary).fixedSize(horizontal: false, vertical: true) @@ -227,7 +229,7 @@ private struct PluginPackageReviewSheet: View { } } } - Text(L("Plugins are advisory. Installing this package does not let it block, rewrite, upload, or export traffic.")) + Text(L("Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.")) .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) HStack { Spacer() diff --git a/Flowlight/UI/RewriteRulesView.swift b/Flowlight/UI/RewriteRulesView.swift index 4a11a43d..5018774e 100644 --- a/Flowlight/UI/RewriteRulesView.swift +++ b/Flowlight/UI/RewriteRulesView.swift @@ -96,9 +96,17 @@ struct RewriteRulesSection: View { struct RewriteRuleEditor: View { @State var rule: RewriteRule var isNew: Bool + var draft: RewriteRuleDraft? var save: (RewriteRule) -> Void @Environment(\.dismiss) private var dismiss + init(rule: RewriteRule, isNew: Bool, draft: RewriteRuleDraft? = nil, save: @escaping (RewriteRule) -> Void) { + _rule = State(initialValue: draft?.rule ?? rule) + self.isNew = isNew + self.draft = draft + self.save = save + } + var body: some View { VStack(alignment: .leading, spacing: 14) { Text(isNew ? L("New request rule") : L("Edit request rule")).font(.title3.bold()) @@ -130,6 +138,18 @@ struct RewriteRuleEditor: View { } } + if let draft { + GroupBox(L("Captured request context")) { + VStack(alignment: .leading, spacing: 3) { + Text("\(draft.method) \(draft.url)").font(.caption.monospaced()).textSelection(.enabled) + if !draft.requestHeaders.isEmpty { + Text(draft.requestHeaders.map { "\($0.name): \($0.value)" }.joined(separator: "\n")) + .font(.caption.monospaced()).textSelection(.enabled).lineLimit(5).foregroundStyle(.secondary) + } + }.frame(maxWidth: .infinity, alignment: .leading) + } + } + Divider() // Header edits diff --git a/FlowlightResponseScriptHelper/main.swift b/FlowlightResponseScriptHelper/main.swift index 73a4750d..5ce2d7c3 100644 --- a/FlowlightResponseScriptHelper/main.swift +++ b/FlowlightResponseScriptHelper/main.swift @@ -106,10 +106,127 @@ func cleanRule(_ value: Any?) -> [String: Any]? { "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) } + if let status = intValue(object["status"]) { clean["status"] = min(max(status, 400), 599) } return clean } +func cleanSuggestedActions(_ value: Any?) -> [[String: Any]] { + let raw = (value as? [[String: Any]]) ?? [] + let kinds = Set(["guardrail", "networkRule", "mockResponse", "requestRewrite", "responseTransform"]) + return raw.prefix(4).compactMap { action in + guard let kind = action["kind"] as? String, kinds.contains(kind) else { return nil } + var clean: [String: Any] = [ + "kind": kind, + "title": String((action["title"] as? String ?? "").prefix(120)), + "reason": String((action["reason"] as? String ?? "").prefix(240)), + ] + switch kind { + case "guardrail": + guard let guardrail = cleanGuardrail(action["guardrail"]) else { return nil } + clean["guardrail"] = guardrail + case "networkRule": + guard let rule = cleanRule(action["networkRule"]) else { return nil } + clean["networkRule"] = rule + case "mockResponse": + guard let mock = cleanMock(action["mock"]) else { return nil } + clean["mock"] = mock + case "requestRewrite": + guard let rewrite = cleanRewrite(action["rewrite"]) else { return nil } + clean["rewrite"] = rewrite + case "responseTransform": + guard let transform = cleanResponseTransform(action["responseTransform"]) else { return nil } + clean["responseTransform"] = transform + default: + return nil + } + return clean + } +} + +func cleanMock(_ value: Any?) -> [String: Any]? { + guard let object = value as? [String: Any] else { return nil } + let host = object["host"] as? String ?? "" + guard !host.isEmpty else { return nil } + return [ + "name": String((object["name"] as? String ?? "").prefix(120)), + "host": String(host.prefix(240)), + "path": String((object["path"] as? String ?? "").prefix(240)), + "method": String((object["method"] as? String ?? "").prefix(20)), + "status": min(max(intValue(object["status"]) ?? 500, 100), 599), + "delay": min(max(doubleValue(object["delay"]) ?? 0, 0), 300), + "headers": cleanHeaders(object["headers"]), + "body": String((object["body"] as? String ?? "").prefix(4096)), + ] +} + +func cleanRewrite(_ value: Any?) -> [String: Any]? { + guard let object = value as? [String: Any] else { return nil } + let headers = cleanHeaderEdits(object["headers"]) + let body = cleanBodyEdits(object["body"]) + guard !headers.isEmpty || !body.isEmpty else { return nil } + return [ + "name": String((object["name"] as? String ?? "").prefix(120)), + "host": String((object["host"] as? String ?? "").prefix(240)), + "path": String((object["path"] as? String ?? "").prefix(240)), + "method": String((object["method"] as? String ?? "").prefix(20)), + "headers": headers, + "body": body, + ] +} + +func cleanResponseTransform(_ value: Any?) -> [String: String]? { + guard let object = value as? [String: Any], let script = object["script"] as? String, !script.isEmpty else { return nil } + return [ + "name": String((object["name"] as? String ?? "").prefix(120)), + "host": String((object["host"] as? String ?? "").prefix(240)), + "path": String((object["path"] as? String ?? "").prefix(240)), + "method": String((object["method"] as? String ?? "").prefix(20)), + "script": String(script.prefix(4096)), + ] +} + +func cleanHeaders(_ value: Any?) -> [[String: String]] { + let raw = (value as? [[String: Any]]) ?? [] + return raw.prefix(16).compactMap { header in + guard let name = header["name"] as? String, !name.isEmpty else { return nil } + return ["name": String(name.prefix(120)), "value": String((header["value"] as? String ?? "").prefix(240))] + } +} + +func cleanHeaderEdits(_ value: Any?) -> [[String: String]] { + let raw = (value as? [[String: Any]]) ?? [] + let ops = Set(["set", "add", "remove"]) + return raw.prefix(16).compactMap { edit in + guard let name = edit["name"] as? String, !name.isEmpty else { return nil } + let op = edit["op"] as? String ?? "set" + guard ops.contains(op) else { return nil } + return ["op": op, "name": String(name.prefix(120)), "value": String((edit["value"] as? String ?? "").prefix(240))] + } +} + +func cleanBodyEdits(_ value: Any?) -> [[String: String]] { + let raw = (value as? [[String: Any]]) ?? [] + let ops = Set(["set", "remove"]) + return raw.prefix(16).compactMap { edit in + guard let path = edit["path"] as? String, !path.isEmpty else { return nil } + let op = edit["op"] as? String ?? "set" + guard ops.contains(op) else { return nil } + return ["op": op, "path": String(path.prefix(160)), "value": String((edit["value"] as? String ?? "").prefix(4096))] + } +} + +func intValue(_ value: Any?) -> Int? { + if let int = value as? Int { return int } + if let double = value as? Double { return Int(double) } + return nil +} + +func doubleValue(_ value: Any?) -> Double? { + if let double = value as? Double { return double } + if let int = value as? Int { return Double(int) } + return nil +} + func runPlugin(_ object: [String: Any]) { guard let script = object["script"] as? String, validScript(script), @@ -147,6 +264,10 @@ func runPlugin(_ object: [String: Any]) { if let rule = cleanRule(finding["suggestedRule"]) { clean["suggestedRule"] = rule } + let actions = cleanSuggestedActions(finding["suggestedActions"]) + if !actions.isEmpty { + clean["suggestedActions"] = actions + } return clean } FileHandle.standardOutput.write(checkedOutput(["findings": findings])) diff --git a/FlowlightTests/PluginTests.swift b/FlowlightTests/PluginTests.swift index 3e9a417e..534a8010 100644 --- a/FlowlightTests/PluginTests.swift +++ b/FlowlightTests/PluginTests.swift @@ -246,6 +246,77 @@ final class PluginTests: XCTestCase { XCTAssertEqual(rule.status, 451) XCTAssertEqual(rule.origin, .alert) XCTAssertTrue(rule.isComplete) + XCTAssertEqual(finding.suggestedActions.first?.kind, .networkRule) + } + + func testJavaScriptPluginCanSuggestReviewedActionsWhenDeclared() throws { + let db = try database() + var manifest = PluginManifest(id: "thirdparty.actions", name: "Actions", version: "0.1.0", kind: .traffic, + source: .installed, publisher: .thirdParty, guardrailProvider: .custom, + description: "JS plugin.", privacySummary: "Uses metadata only.", + capabilities: [.annotate, .suggestAction], permissions: [.trafficMetadata, .appIdentity, .suggestAction]) + manifest.script = """ + function evaluate(context) { + return [{ + severity: 'medium', + title: 'Suggest reviewed actions', + summary: 'Suggest reviewed editor handoffs.', + evidence: [{ label: 'Host', value: context.exchange.host }], + suggestedActions: [ + { + kind: 'mockResponse', + title: 'Review mock outage', + reason: 'Exercise retry handling.', + mock: { host: context.exchange.host, path: context.exchange.path, method: context.exchange.method, status: 503, delay: 2, body: '{"error":"try later"}' } + }, + { + kind: 'requestRewrite', + title: 'Review rewrite', + reason: 'Pin a safe option.', + rewrite: { host: context.exchange.host, path: context.exchange.path, method: context.exchange.method, body: [{ op: 'set', path: 'metadata.safe', value: 'true' }] } + }, + { + kind: 'responseTransform', + title: 'Review transform', + reason: 'Remove a noisy response field.', + responseTransform: { host: context.exchange.host, path: context.exchange.path, method: context.exchange.method, script: 'function modifyResponse(args) { return args.responseJSON; }' } + } + ] + }]; + } + """ + 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.actions" }) + XCTAssertEqual(finding.suggestedActions.map(\.kind), [.mockResponse, .requestRewrite, .responseTransform]) + XCTAssertEqual(finding.suggestedActions[0].mock?.path, "/v1/messages") + XCTAssertEqual(finding.suggestedActions[0].mock?.status, 503) + XCTAssertEqual(finding.suggestedActions[1].rewrite?.body.first?.path, "metadata.safe") + XCTAssertEqual(finding.suggestedActions[2].responseTransform?.script, "function modifyResponse(args) { return args.responseJSON; }") + } + + func testMalformedReviewedActionIsDroppedButFindingPersists() throws { + let db = try database() + var manifest = PluginManifest(id: "thirdparty.bad-action", name: "Bad Action", version: "0.1.0", kind: .traffic, + source: .installed, publisher: .thirdParty, guardrailProvider: .custom, + description: "JS plugin.", privacySummary: "Uses metadata only.", + capabilities: [.annotate, .suggestAction], permissions: [.trafficMetadata, .suggestAction]) + manifest.script = """ + function evaluate(context) { + return [{ + severity: 'low', + title: 'Malformed action', + summary: 'The finding should survive.', + evidence: [{ label: 'Host', value: context.exchange.host }], + suggestedActions: [{ kind: 'requestRewrite', title: 'Bad rewrite', rewrite: { headers: [], body: [] } }] + }]; + } + """ + 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.bad-action" }) + XCTAssertEqual(finding.title, "Malformed action") + XCTAssertTrue(finding.suggestedActions.isEmpty) } @MainActor diff --git a/docs/llms-full.txt b/docs/llms-full.txt index a9380227..ed5de496 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -1321,13 +1321,13 @@ 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 or rules for the user to apply, 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 reviewed Flowlight actions that open existing editors, but it does not block, rewrite, mock, transform, or save settings 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 - A manifest tells Flowlight what the plugin is, who publishes it, what sanitized metadata it uses, whether it can suggest guardrails, and optionally the JavaScript evaluate(context) script to run. + A manifest tells Flowlight what the plugin is, who publishes it, what sanitized metadata it uses, whether it can suggest reviewed actions, and optionally the JavaScript evaluate(context) script to run. { "id": "thirdparty.example-guardrails", @@ -1345,8 +1345,8 @@ Plugins "categories": ["metadataOnly", "toolNames", "mcpMetadata", "guardrailSuggestions"], "summary": "Uses sanitized metadata only." }, - "permissions": ["trafficMetadata", "appIdentity", "llmMetadata", "mcpMetadata", "suggestGuardrail"], - "capabilities": ["annotate", "suggestGuardrail"], + "permissions": ["trafficMetadata", "appIdentity", "llmMetadata", "mcpMetadata", "suggestAction"], + "capabilities": ["annotate", "suggestAction"], "script": "function evaluate(context) { return []; }" } @@ -1356,9 +1356,9 @@ Plugins publisherofficial, thirdPartyOfficial status is trusted only when package provenance verifies; unsigned packages are treated as third-party. - permissionstrafficMetadata, headerNames, appIdentity, llmMetadata, mcpMetadata, suggestGuardrailWhich sanitized metadata families the plugin expects. These are not mutation powers. + permissionstrafficMetadata, headerNames, appIdentity, llmMetadata, mcpMetadata, suggestActionWhich sanitized metadata families the plugin expects. These are not mutation powers. - capabilitiesannotate, suggestGuardrailCapabilities are advisory; enforcement still uses Flowlight rules and guardrails. + capabilitiesannotate, suggestActionCapabilities are advisory; enforcement still uses Flowlight rules, guardrails, mock, rewrite and transform editors. Provenance and package review @@ -1372,7 +1372,7 @@ 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. + When a manifest declares the suggestAction capability and permission, a finding can also include suggestedActions. Flowlight stores those as inert suggestions; Inspect opens the matching editor and saves only when the user reviews and clicks Save. { "severity": "medium", @@ -1382,13 +1382,18 @@ Plugins { "label": "Tool", "value": "Bash" }, { "label": "Provider", "value": "Flowlight" } ], - "suggestedGuardrail": { - "agent": "claude", - "server": "", - "tool": "Bash", - "origin": "observed" + "suggestedActions": [ + { + "kind": "guardrail", + "title": "Review guardrail", + "reason": "Refuse this tool only after reviewing the normal Flowlight guardrail.", + "guardrail": { "agent": "claude", "server": "", "tool": "Bash" } }, - "suggestedRule": { + { + "kind": "networkRule", + "title": "Review Claude API traffic", + "reason": "Create a normal Flowlight rule if this endpoint should be refused.", + "networkRule": { "action": "block", "app": "claude", "destination": "api.example.com", @@ -1397,6 +1402,8 @@ Plugins "status": 403, "name": "Review Claude API traffic" } + } + ] } Supported guardrail providers diff --git a/docs/plugins/index.html b/docs/plugins/index.html index 9dfa0aad..7339fbc6 100644 --- a/docs/plugins/index.html +++ b/docs/plugins/index.html @@ -74,11 +74,11 @@

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 or rules for the user to apply, 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 reviewed Flowlight actions that open existing editors, but it does not block, rewrite, mock, transform, or save settings 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

-

A manifest tells Flowlight what the plugin is, who publishes it, what sanitized metadata it uses, whether it can suggest guardrails, and optionally the JavaScript evaluate(context) script to run.

+

A manifest tells Flowlight what the plugin is, who publishes it, what sanitized metadata it uses, whether it can suggest reviewed actions, and optionally the JavaScript evaluate(context) script to run.

{
   "id": "thirdparty.example-guardrails",
   "name": "Example Guardrails",
@@ -95,16 +95,16 @@ 

Manifest

"categories": ["metadataOnly", "toolNames", "mcpMetadata", "guardrailSuggestions"], "summary": "Uses sanitized metadata only." }, - "permissions": ["trafficMetadata", "appIdentity", "llmMetadata", "mcpMetadata", "suggestGuardrail"], - "capabilities": ["annotate", "suggestGuardrail"], + "permissions": ["trafficMetadata", "appIdentity", "llmMetadata", "mcpMetadata", "suggestAction"], + "capabilities": ["annotate", "suggestAction"], "script": "function evaluate(context) { return []; }" }
- - + +
FieldValuesMeaning
kindtraffic, llmMCPWhether the plugin describes ordinary inspected HTTP or model/tool/MCP activity.
publisherofficial, thirdPartyOfficial status is trusted only when package provenance verifies; unsigned packages are treated as third-party.
permissionstrafficMetadata, headerNames, appIdentity, llmMetadata, mcpMetadata, suggestGuardrailWhich sanitized metadata families the plugin expects. These are not mutation powers.
capabilitiesannotate, suggestGuardrailCapabilities are advisory; enforcement still uses Flowlight rules and guardrails.
permissionstrafficMetadata, headerNames, appIdentity, llmMetadata, mcpMetadata, suggestActionWhich sanitized metadata families the plugin expects. These are not mutation powers.
capabilitiesannotate, suggestActionCapabilities are advisory; enforcement still uses Flowlight rules, guardrails, mock, rewrite and transform editors.

Provenance and package review

@@ -114,7 +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.

+

When a manifest declares the suggestAction capability and permission, a finding can also include suggestedActions. Flowlight stores those as inert suggestions; Inspect opens the matching editor and saves only when the user reviews and clicks Save.

{
   "severity": "medium",
   "title": "High-risk tool available",
@@ -123,21 +123,28 @@ 

Findings

{ "label": "Tool", "value": "Bash" }, { "label": "Provider", "value": "Flowlight" } ], - "suggestedGuardrail": { - "agent": "claude", - "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" - } + "suggestedActions": [ + { + "kind": "guardrail", + "title": "Review guardrail", + "reason": "Refuse this tool only after reviewing the normal Flowlight guardrail.", + "guardrail": { "agent": "claude", "server": "", "tool": "Bash" } + }, + { + "kind": "networkRule", + "title": "Review Claude API traffic", + "reason": "Create a normal Flowlight rule if this endpoint should be refused.", + "networkRule": { + "action": "block", + "app": "claude", + "destination": "api.example.com", + "path": "/v1/messages", + "method": "POST", + "status": 403, + "name": "Review Claude API traffic" + } + } + ] }

Supported guardrail providers

diff --git a/site/pages/plugins.html b/site/pages/plugins.html index 5d8c167d..cadcd3f3 100644 --- a/site/pages/plugins.html +++ b/site/pages/plugins.html @@ -26,11 +26,11 @@

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 or rules for the user to apply, 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 reviewed Flowlight actions that open existing editors, but it does not block, rewrite, mock, transform, or save settings 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

-

A manifest tells Flowlight what the plugin is, who publishes it, what sanitized metadata it uses, whether it can suggest guardrails, and optionally the JavaScript evaluate(context) script to run.

+

A manifest tells Flowlight what the plugin is, who publishes it, what sanitized metadata it uses, whether it can suggest reviewed actions, and optionally the JavaScript evaluate(context) script to run.

{
   "id": "thirdparty.example-guardrails",
   "name": "Example Guardrails",
@@ -47,16 +47,16 @@ 

Manifest

"categories": ["metadataOnly", "toolNames", "mcpMetadata", "guardrailSuggestions"], "summary": "Uses sanitized metadata only." }, - "permissions": ["trafficMetadata", "appIdentity", "llmMetadata", "mcpMetadata", "suggestGuardrail"], - "capabilities": ["annotate", "suggestGuardrail"], + "permissions": ["trafficMetadata", "appIdentity", "llmMetadata", "mcpMetadata", "suggestAction"], + "capabilities": ["annotate", "suggestAction"], "script": "function evaluate(context) { return []; }" }
- - + +
FieldValuesMeaning
kindtraffic, llmMCPWhether the plugin describes ordinary inspected HTTP or model/tool/MCP activity.
publisherofficial, thirdPartyOfficial status is trusted only when package provenance verifies; unsigned packages are treated as third-party.
permissionstrafficMetadata, headerNames, appIdentity, llmMetadata, mcpMetadata, suggestGuardrailWhich sanitized metadata families the plugin expects. These are not mutation powers.
capabilitiesannotate, suggestGuardrailCapabilities are advisory; enforcement still uses Flowlight rules and guardrails.
permissionstrafficMetadata, headerNames, appIdentity, llmMetadata, mcpMetadata, suggestActionWhich sanitized metadata families the plugin expects. These are not mutation powers.
capabilitiesannotate, suggestActionCapabilities are advisory; enforcement still uses Flowlight rules, guardrails, mock, rewrite and transform editors.

Provenance and package review

@@ -66,7 +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.

+

When a manifest declares the suggestAction capability and permission, a finding can also include suggestedActions. Flowlight stores those as inert suggestions; Inspect opens the matching editor and saves only when the user reviews and clicks Save.

{
   "severity": "medium",
   "title": "High-risk tool available",
@@ -75,21 +75,28 @@ 

Findings

{ "label": "Tool", "value": "Bash" }, { "label": "Provider", "value": "Flowlight" } ], - "suggestedGuardrail": { - "agent": "claude", - "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" - } + "suggestedActions": [ + { + "kind": "guardrail", + "title": "Review guardrail", + "reason": "Refuse this tool only after reviewing the normal Flowlight guardrail.", + "guardrail": { "agent": "claude", "server": "", "tool": "Bash" } + }, + { + "kind": "networkRule", + "title": "Review Claude API traffic", + "reason": "Create a normal Flowlight rule if this endpoint should be refused.", + "networkRule": { + "action": "block", + "app": "claude", + "destination": "api.example.com", + "path": "/v1/messages", + "method": "POST", + "status": 403, + "name": "Review Claude API traffic" + } + } + ] }

Supported guardrail providers

diff --git a/templates/inspection-plugin/README.md b/templates/inspection-plugin/README.md index b06dd4d1..25889637 100644 --- a/templates/inspection-plugin/README.md +++ b/templates/inspection-plugin/README.md @@ -4,13 +4,13 @@ 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 or rules for the user to apply, 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 reviewed Flowlight actions for the user to open in existing editors, but they do not block, rewrite, mock, transform, upload, export, or save settings 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. ## Review metadata -`permissions` declares which sanitized metadata families the plugin expects, such as `trafficMetadata`, `headerNames`, `appIdentity`, `llmMetadata`, `mcpMetadata`, and `suggestGuardrail`. These are not mutation powers. +`permissions` declares which sanitized metadata families the plugin expects, such as `trafficMetadata`, `headerNames`, `appIdentity`, `llmMetadata`, `mcpMetadata`, and `suggestAction`. The legacy `suggestGuardrail` spelling is still accepted for older packages. These are not mutation powers. `privacy` provides structured categories for the review sheet. Keep `privacySummary` human-readable and specific enough that someone can tell what the plugin sees. @@ -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 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. +Return up to 8 findings. Each finding uses `severity`, `title`, `summary`, bounded `evidence`, and optional `suggestedActions`. Actions can be `guardrail`, `networkRule`, `mockResponse`, `requestRewrite`, or `responseTransform`; each opens the matching Flowlight editor with bounded prefilled fields and is saved only if the user reviews and clicks Save. Suggestions require the `suggestAction` 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 a673bb4f..4ecbc06f 100644 --- a/templates/inspection-plugin/findings.example.json +++ b/templates/inspection-plugin/findings.example.json @@ -7,12 +7,18 @@ { "label": "Tool", "value": "Bash" }, { "label": "Provider", "value": "Flowlight" } ], - "suggestedGuardrail": { - "agent": "claude", - "server": "", - "tool": "Bash", - "origin": "observed" - } + "suggestedActions": [ + { + "kind": "guardrail", + "title": "Review guardrail", + "reason": "Refuse this tool only after reviewing the normal Flowlight guardrail.", + "guardrail": { + "agent": "claude", + "server": "", + "tool": "Bash" + } + } + ] }, { "severity": "low", @@ -23,14 +29,21 @@ { "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" - } + "suggestedActions": [ + { + "kind": "networkRule", + "title": "Review provider connector traffic", + "reason": "Use this only if the same host is contacted locally by an app or agent.", + "networkRule": { + "action": "block", + "app": "claude", + "destination": "api.github.com", + "path": "", + "method": "", + "status": 403, + "name": "Review provider connector traffic" + } + } + ] } ] diff --git a/templates/inspection-plugin/manifest.json b/templates/inspection-plugin/manifest.json index 0b613cc3..257f8ae2 100644 --- a/templates/inspection-plugin/manifest.json +++ b/templates/inspection-plugin/manifest.json @@ -12,10 +12,21 @@ "schemaVersion": 1, "privacySummary": "Uses provider names, tool names, MCP server names, request sizes and redacted evidence only.", "privacy": { - "categories": ["metadataOnly", "toolNames", "mcpMetadata", "guardrailSuggestions"], + "categories": [ + "metadataOnly", + "toolNames", + "mcpMetadata", + "guardrailSuggestions" + ], "summary": "Uses sanitized metadata only; no bodies, prompts, completions, header values or full tool inputs." }, - "permissions": ["trafficMetadata", "appIdentity", "llmMetadata", "mcpMetadata", "suggestGuardrail"], + "permissions": [ + "trafficMetadata", + "appIdentity", + "llmMetadata", + "mcpMetadata", + "suggestAction" + ], "provenance": { "sourceURL": "", "repositoryURL": "", @@ -23,6 +34,9 @@ "publisherName": "Example Publisher", "reviewStatus": "unsigned" }, - "capabilities": ["annotate", "suggestGuardrail"], - "script": "function evaluate(context) {\n const findings = [];\n const tools = (context.exchange.llm && context.exchange.llm.declaredTools) || [];\n for (const tool of tools) {\n if (!/(bash|shell|exec|write|edit|delete|remove|patch)/i.test(tool.name)) continue;\n findings.push({\n severity: 'medium',\n title: 'High-risk tool available',\n summary: 'The agent declared a shell-like or write-capable tool. Add a guardrail if this tool should be refused.',\n evidence: [\n { label: 'Tool', value: tool.name },\n { label: 'Provider', value: context.exchange.llm.provider }\n ],\n suggestedGuardrail: {\n agent: context.exchange.agent || '',\n server: tool.server || '',\n tool: tool.name,\n origin: 'observed'\n }\n });\n }\n\n const connectors = (context.exchange.llm && context.exchange.llm.connectors) || [];\n for (const connector of connectors) {\n findings.push({\n severity: connector.authorized ? 'medium' : 'low',\n title: 'Provider-run MCP connector',\n summary: 'The model provider offered an MCP connector. Local network rules cannot block provider-side calls unless the same host is also contacted locally.',\n evidence: [\n { label: 'Provider', value: connector.provider },\n { label: 'Connector', value: connector.label },\n { label: 'Approval', value: connector.approval || 'unspecified' }\n ]\n });\n }\n\n return findings;\n}\n" + "capabilities": [ + "annotate", + "suggestAction" + ], + "script": "function evaluate(context) {\n const findings = [];\n const tools = (context.exchange.llm && context.exchange.llm.declaredTools) || [];\n for (const tool of tools) {\n if (!/(bash|shell|exec|write|edit|delete|remove|patch)/i.test(tool.name)) continue;\n findings.push({\n severity: 'medium',\n title: 'High-risk tool available',\n summary: 'The agent declared a shell-like or write-capable tool. Add a guardrail if this tool should be refused.',\n evidence: [\n { label: 'Tool', value: tool.name },\n { label: 'Provider', value: context.exchange.llm.provider }\n ],\n suggestedActions: [{\n kind: 'guardrail',\n title: 'Review guardrail',\n reason: 'Refuse this tool only after reviewing the normal Flowlight guardrail.',\n guardrail: {\n agent: context.exchange.agent || '',\n server: tool.server || '',\n tool: tool.name\n }\n }]\n });\n }\n\n const connectors = (context.exchange.llm && context.exchange.llm.connectors) || [];\n for (const connector of connectors) {\n findings.push({\n severity: connector.authorized ? 'medium' : 'low',\n title: 'Provider-run MCP connector',\n summary: 'The model provider offered an MCP connector. Local network rules cannot block provider-side calls unless the same host is also contacted locally.',\n evidence: [\n { label: 'Provider', value: connector.provider },\n { label: 'Connector', value: connector.label },\n { label: 'Approval', value: connector.approval || 'unspecified' }\n ],\n suggestedActions: [{\n kind: 'networkRule',\n title: 'Review provider connector traffic',\n reason: 'Use this only if the same host is contacted locally by an app or agent.',\n networkRule: {\n action: 'block',\n app: context.exchange.agent || context.exchange.bundleID || '',\n destination: 'api.github.com',\n status: 403,\n name: 'Review provider connector traffic'\n }\n }]\n });\n }\n\n return findings;\n}\n" } diff --git a/templates/inspection-plugin/plugin.js b/templates/inspection-plugin/plugin.js index 3b2f8722..bfcc6b43 100644 --- a/templates/inspection-plugin/plugin.js +++ b/templates/inspection-plugin/plugin.js @@ -11,12 +11,16 @@ function evaluate(context) { { label: 'Tool', value: tool.name }, { label: 'Provider', value: context.exchange.llm.provider } ], - suggestedGuardrail: { - agent: context.exchange.agent || '', - server: tool.server || '', - tool: tool.name, - origin: 'observed' - } + suggestedActions: [{ + kind: 'guardrail', + title: 'Review guardrail', + reason: 'Refuse this tool only after reviewing the normal Flowlight guardrail.', + guardrail: { + agent: context.exchange.agent || '', + server: tool.server || '', + tool: tool.name + } + }] }); } @@ -30,7 +34,19 @@ function evaluate(context) { { label: 'Provider', value: connector.provider }, { label: 'Connector', value: connector.label }, { label: 'Approval', value: connector.approval || 'unspecified' } - ] + ], + suggestedActions: [{ + kind: 'networkRule', + title: 'Review provider connector traffic', + reason: 'Use this only if the same host is contacted locally by an app or agent.', + networkRule: { + action: 'block', + app: context.exchange.agent || context.exchange.bundleID || '', + destination: 'api.github.com', + status: 403, + name: 'Review provider connector traffic' + } + }] }); } diff --git a/templates/inspection-plugin/validate.js b/templates/inspection-plugin/validate.js index 8df6b366..2ddae6af 100644 --- a/templates/inspection-plugin/validate.js +++ b/templates/inspection-plugin/validate.js @@ -8,8 +8,8 @@ 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 permissions = new Set(['trafficMetadata', 'headerNames', 'appIdentity', 'llmMetadata', 'mcpMetadata', 'suggestGuardrail']); +const capabilities = new Set(['annotate', 'suggestGuardrail', 'suggestAction']); +const permissions = new Set(['trafficMetadata', 'headerNames', 'appIdentity', 'llmMetadata', 'mcpMetadata', 'suggestGuardrail', 'suggestAction']); const privacyCategories = new Set(['metadataOnly', 'headerNames', 'appIdentity', 'toolNames', 'mcpMetadata', 'guardrailSuggestions']); const channels = new Set(['builtIn', 'local', 'community', 'official']); const reviewStatuses = new Set(['builtIn', 'unsigned', 'verifiedOfficial', 'verifiedCommunity', 'rejected']); @@ -52,7 +52,10 @@ function checkManifest(manifest) { requireCondition(Array.isArray(manifest.permissions), 'permissions must be an array'); requireCondition(manifest.permissions.every(permission => permissions.has(permission)), 'unsupported permission'); if (manifest.capabilities.includes('suggestGuardrail')) { - requireCondition(manifest.permissions.includes('suggestGuardrail'), 'suggestGuardrail capability must declare suggestGuardrail permission'); + requireCondition(manifest.permissions.includes('suggestGuardrail') || manifest.permissions.includes('suggestAction'), 'suggestGuardrail capability must declare a suggestion permission'); + } + if (manifest.capabilities.includes('suggestAction')) { + requireCondition(manifest.permissions.includes('suggestAction') || manifest.permissions.includes('suggestGuardrail'), 'suggestAction capability must declare a suggestion permission'); } } if (manifest.privacy !== undefined) { @@ -83,6 +86,87 @@ function checkManifest(manifest) { } } +function checkGuardrail(value, source) { + requireCondition(isPlainObject(value), `${source} guardrail must be an object`); + for (const key of ['agent', 'server', 'tool', 'resource', 'name']) { + requireCondition(typeof (value[key] || '') === 'string', `${source} guardrail ${key} must be a string`); + } + requireCondition((value.server || value.tool || value.resource), `${source} guardrail must name a server, tool or resource`); +} + +function checkNetworkRule(value, source) { + requireCondition(isPlainObject(value), `${source} networkRule must be an object`); + requireCondition(['block', 'allow'].includes(value.action || 'block'), `${source} networkRule action must be block or allow`); + requireCondition((value.app || value.destination), `${source} networkRule must name an app or destination`); + for (const key of ['app', 'destination', 'path', 'method', 'name']) { + requireCondition(typeof (value[key] || '') === 'string', `${source} networkRule ${key} must be a string`); + } + if (value.status !== undefined) { + requireCondition(Number.isInteger(value.status) && value.status >= 400 && value.status <= 599, `${source} networkRule status must be 400-599`); + } +} + +function checkHeaders(headers, source) { + requireCondition(headers === undefined || Array.isArray(headers), `${source} headers must be an array`); + (headers || []).forEach((header, index) => { + requireCondition(isPlainObject(header), `${source} header ${index} must be an object`); + requireCondition(typeof (header.name || '') === 'string' && header.name.length <= 120, `${source} header ${index} name must be short text`); + requireCondition(typeof (header.value || '') === 'string' && header.value.length <= 240, `${source} header ${index} value must be short text`); + }); +} + +function checkHeaderEdits(edits, source) { + requireCondition(edits === undefined || Array.isArray(edits), `${source} header edits must be an array`); + (edits || []).forEach((edit, index) => { + requireCondition(isPlainObject(edit), `${source} header edit ${index} must be an object`); + requireCondition(['set', 'add', 'remove'].includes(edit.op || 'set'), `${source} header edit ${index} op is unsupported`); + requireCondition(typeof (edit.name || '') === 'string' && edit.name.length <= 120, `${source} header edit ${index} name must be short text`); + requireCondition(typeof (edit.value || '') === 'string' && edit.value.length <= 240, `${source} header edit ${index} value must be short text`); + }); +} + +function checkBodyEdits(edits, source) { + requireCondition(edits === undefined || Array.isArray(edits), `${source} body edits must be an array`); + (edits || []).forEach((edit, index) => { + requireCondition(isPlainObject(edit), `${source} body edit ${index} must be an object`); + requireCondition(['set', 'remove'].includes(edit.op || 'set'), `${source} body edit ${index} op is unsupported`); + requireCondition(typeof (edit.path || '') === 'string' && edit.path.length <= 160, `${source} body edit ${index} path must be short text`); + requireCondition(typeof (edit.value || '') === 'string' && edit.value.length <= 4096, `${source} body edit ${index} value is too long`); + }); +} + +function checkSuggestedAction(action, source) { + requireCondition(isPlainObject(action), `${source} suggested action must be an object`); + requireCondition(['guardrail', 'networkRule', 'mockResponse', 'requestRewrite', 'responseTransform'].includes(action.kind), `${source} suggested action kind is unsupported`); + requireCondition(typeof (action.title || '') === 'string' && action.title.length <= 120, `${source} suggested action title is too long`); + requireCondition(typeof (action.reason || '') === 'string' && action.reason.length <= 240, `${source} suggested action reason is too long`); + if (action.kind === 'guardrail') checkGuardrail(action.guardrail, source); + if (action.kind === 'networkRule') checkNetworkRule(action.networkRule, source); + if (action.kind === 'mockResponse') { + const mock = action.mock; + requireCondition(isPlainObject(mock), `${source} mock must be an object`); + requireCondition(typeof (mock.host || '') === 'string' && mock.host.length <= 240, `${source} mock host must be short text`); + requireCondition(typeof (mock.path || '') === 'string' && mock.path.length <= 240, `${source} mock path must be short text`); + requireCondition(typeof (mock.method || '') === 'string' && mock.method.length <= 20, `${source} mock method must be short text`); + requireCondition(mock.status === undefined || (Number.isInteger(mock.status) && mock.status >= 100 && mock.status <= 599), `${source} mock status must be 100-599`); + requireCondition(mock.delay === undefined || (typeof mock.delay === 'number' && mock.delay >= 0 && mock.delay <= 300), `${source} mock delay must be 0-300`); + requireCondition(typeof (mock.body || '') === 'string' && mock.body.length <= 4096, `${source} mock body is too long`); + checkHeaders(mock.headers, `${source} mock`); + } + if (action.kind === 'requestRewrite') { + const rewrite = action.rewrite; + requireCondition(isPlainObject(rewrite), `${source} rewrite must be an object`); + checkHeaderEdits(rewrite.headers, `${source} rewrite`); + checkBodyEdits(rewrite.body, `${source} rewrite`); + requireCondition((rewrite.headers || []).length > 0 || (rewrite.body || []).length > 0, `${source} rewrite must include an edit`); + } + if (action.kind === 'responseTransform') { + const transform = action.responseTransform; + requireCondition(isPlainObject(transform), `${source} responseTransform must be an object`); + requireCondition(typeof (transform.script || '') === 'string' && transform.script.length > 0 && transform.script.length <= 4096, `${source} responseTransform script must be 1-4096 characters`); + } +} + function checkFindings(findings, source = 'findings') { requireCondition(Array.isArray(findings), `${source} must be an array`); findings.forEach((finding, index) => { @@ -98,16 +182,15 @@ 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.suggestedGuardrail !== undefined) { + checkGuardrail(finding.suggestedGuardrail, `${source} ${index} suggestedGuardrail`); + } 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`); - } + checkNetworkRule(finding.suggestedRule, `${source} ${index} suggestedRule`); + } + if (finding.suggestedActions !== undefined) { + requireCondition(Array.isArray(finding.suggestedActions) && finding.suggestedActions.length <= 4, `${source} ${index} suggestedActions must be a short array`); + finding.suggestedActions.forEach((action, actionIndex) => checkSuggestedAction(action, `${source} ${index} action ${actionIndex}`)); } }); }