From 8b01c9d0083e895d3079b6bafc29d53dd49dad19 Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Tue, 6 Oct 2026 09:01:51 -0700
Subject: [PATCH] Add dynamic response transforms
Co-Authored-By: Claude Code
---
.../Inspection/InspectionController.swift | 44 ++-
Flowlight/Inspection/InspectionProxy.swift | 32 +-
Flowlight/Inspection/InspectionRecorder.swift | 17 +-
Flowlight/Inspection/MockRule.swift | 4 +-
Flowlight/Inspection/ResponseGate.swift | 161 +++++++++
.../Inspection/ResponseScriptRunner.swift | 108 +++++++
.../Inspection/ResponseTransformRule.swift | 187 +++++++++++
Flowlight/Storage/TrafficDatabase.swift | 15 +-
Flowlight/UI/InspectView.swift | 67 +++-
Flowlight/UI/ResponseTransformRulesView.swift | 135 ++++++++
FlowlightResponseScriptHelper/main.swift | 61 ++++
FlowlightTests/MockResponseTests.swift | 1 +
FlowlightTests/ResponseTransformTests.swift | 306 ++++++++++++++++++
docs/404.html | 2 +-
docs/about/index.html | 2 +-
docs/de/about/index.html | 2 +-
docs/de/docs/index.html | 4 +-
docs/de/index.html | 6 +-
docs/de/privacy/index.html | 2 +-
docs/de/threat-model/index.html | 2 +-
docs/docs/index.html | 4 +-
docs/es/about/index.html | 2 +-
docs/es/docs/index.html | 4 +-
docs/es/index.html | 6 +-
docs/es/privacy/index.html | 2 +-
docs/es/threat-model/index.html | 2 +-
docs/fr/about/index.html | 2 +-
docs/fr/docs/index.html | 4 +-
docs/fr/index.html | 6 +-
docs/fr/privacy/index.html | 2 +-
docs/fr/threat-model/index.html | 2 +-
docs/index.html | 6 +-
docs/it/about/index.html | 2 +-
docs/it/docs/index.html | 4 +-
docs/it/index.html | 6 +-
docs/it/privacy/index.html | 2 +-
docs/it/threat-model/index.html | 2 +-
docs/ja/about/index.html | 2 +-
docs/ja/docs/index.html | 4 +-
docs/ja/index.html | 6 +-
docs/ja/privacy/index.html | 2 +-
docs/ja/threat-model/index.html | 2 +-
docs/ko/about/index.html | 2 +-
docs/ko/docs/index.html | 4 +-
docs/ko/index.html | 6 +-
docs/ko/privacy/index.html | 2 +-
docs/ko/threat-model/index.html | 2 +-
docs/llms-full.txt | 22 +-
docs/llms.txt | 2 +-
docs/privacy/index.html | 2 +-
docs/pt-PT/about/index.html | 2 +-
docs/pt-PT/docs/index.html | 4 +-
docs/pt-PT/index.html | 6 +-
docs/pt-PT/privacy/index.html | 2 +-
docs/pt-PT/threat-model/index.html | 2 +-
docs/releases/index.html | 20 +-
docs/sitemap.xml | 2 +-
docs/threat-model/index.html | 2 +-
docs/zh-Hans/about/index.html | 2 +-
docs/zh-Hans/docs/index.html | 4 +-
docs/zh-Hans/index.html | 6 +-
docs/zh-Hans/privacy/index.html | 2 +-
docs/zh-Hans/threat-model/index.html | 2 +-
docs/zh-Hant/about/index.html | 2 +-
docs/zh-Hant/docs/index.html | 4 +-
docs/zh-Hant/index.html | 6 +-
docs/zh-Hant/privacy/index.html | 2 +-
docs/zh-Hant/threat-model/index.html | 2 +-
project.yml | 20 +-
site/pages/releases.html | 18 +-
70 files changed, 1275 insertions(+), 109 deletions(-)
create mode 100644 Flowlight/Inspection/ResponseGate.swift
create mode 100644 Flowlight/Inspection/ResponseScriptRunner.swift
create mode 100644 Flowlight/Inspection/ResponseTransformRule.swift
create mode 100644 Flowlight/UI/ResponseTransformRulesView.swift
create mode 100644 FlowlightResponseScriptHelper/main.swift
create mode 100644 FlowlightTests/ResponseTransformTests.swift
diff --git a/Flowlight/Inspection/InspectionController.swift b/Flowlight/Inspection/InspectionController.swift
index db7cd75..463e01b 100644
--- a/Flowlight/Inspection/InspectionController.swift
+++ b/Flowlight/Inspection/InspectionController.swift
@@ -20,6 +20,8 @@ final class InspectionController: ObservableObject {
static let mockRules = "inspection.mockRules"
/// Rules that rewrite an outgoing request's headers or JSON body before it is forwarded (see `RewriteRule`).
static let rewriteRules = "inspection.rewriteRules"
+ /// Scripts that change eligible upstream responses after the origin answered (see `ResponseTransformRule`).
+ static let responseTransformRules = "inspection.responseTransformRules"
/// When the running session was switched on, so its end survives a relaunch.
static let sessionStarted = "inspection.sessionStarted"
/// Names of the agents the user asked Flowlight to keep routed through the proxy by editing their own
@@ -98,6 +100,7 @@ final class InspectionController: ObservableObject {
}
let mockRules = { Self.decodeMockRules(UserDefaults.standard.data(forKey: Keys.mockRules)) }
let rewriteRules = { Self.decodeRewriteRules(UserDefaults.standard.data(forKey: Keys.rewriteRules)) }
+ let responseTransforms = { Self.decodeResponseTransformRules(UserDefaults.standard.data(forKey: Keys.responseTransformRules)) }
// A rule refusing a request is answered by the same machinery that gives a mock its canned response, and
// it goes first: a block someone wrote has to outrank a mock they left switched on.
let answersFor = { [weak self, recorder, proxy] (host: String, clientPort: UInt16) -> [MockRule] in
@@ -161,6 +164,25 @@ final class InspectionController: ObservableObject {
return .replace(current, note: notes.joined(separator: " · "))
}
}
+ proxy.responseInterventions = { flow in
+ let rules = ResponseTransformRules.matching(responseTransforms(), host: flow.host)
+ guard !rules.isEmpty else { return nil }
+ return ResponseGate.Intervention(
+ transform: { context, responseHead, responseBody in
+ let applicable = ResponseTransformRules.applicable(rules, host: flow.host, method: context.head.method, path: context.head.target)
+ guard !applicable.isEmpty else { return nil }
+ // The runner fails open: an unavailable helper, invalid script, bad output, or timeout leaves the
+ // upstream bytes untouched instead of making an application fail because its diagnostic rule did.
+ guard let replacement = ResponseScriptRunner.shared.transform(rules: applicable,
+ request: ResponseTransformRequest(head: context.head, host: flow.host, scheme: flow.scheme, port: flow.port),
+ responseHead: responseHead, responseBody: responseBody) else { return nil }
+ return ResponseGate.Replacement(body: replacement.body, note: replacement.note)
+ },
+ shouldTransform: { context in
+ !ResponseTransformRules.applicable(rules, host: flow.host, method: context.head.method, path: context.head.target).isEmpty
+ }
+ )
+ }
proxy.onAnswered = { [weak self, recorder, proxy] rule, flow, head in
guard rule.blocked, let refused = (self?.requestRules() ?? []).first(where: { $0.id == rule.id }) else { return }
let owner = recorder.owner(clientPort: flow.clientPort, proxyPort: proxy.port)
@@ -179,8 +201,9 @@ final class InspectionController: ObservableObject {
// A host someone wrote a mock rule for is decrypted whatever the scope says: a rule can only answer a
// request Flowlight can read, and "my mock didn't fire" is a bad afternoon.
guard answersFor(host, clientPort).isEmpty else { answer(true); return }
- // Same for a host with a rewrite rule: it can only edit a request Flowlight can read.
+ // Same for a host with a rewrite or response-transform rule: it can only edit bytes Flowlight can read.
guard RewriteRules.matching(rewriteRules(), host: host).isEmpty else { answer(true); return }
+ guard ResponseTransformRules.matching(responseTransforms(), host: host).isEmpty else { answer(true); return }
guard scope == .agents else { answer(true); return }
decide.async {
answer(recorder.owner(clientPort: clientPort, proxyPort: proxy.port).agent != nil)
@@ -190,7 +213,7 @@ final class InspectionController: ObservableObject {
// Plain-HTTP requests reach the recorder regardless of scope; keep only what the scope allows.
let (scope, _) = scopeAndList()
// A mocked exchange is always kept: an answer Flowlight invented has to be visible wherever it lands.
- guard scope == .all || exchange.agent != nil || exchange.note != nil || exchange.mockRule != nil else { return }
+ guard scope == .all || exchange.agent != nil || exchange.note != nil || exchange.mockRule != nil || exchange.responseTransform != nil else { return }
guard let self else { return }
Task { @MainActor in
// Capture is committed first. The database transaction optionally creates a tiny derived candidate;
@@ -314,6 +337,23 @@ final class InspectionController: ObservableObject {
return (try? JSONDecoder().decode([RewriteRule].self, from: data)) ?? []
}
+ /// Scripts that change a real server response before the client sees it. Like other inspection rules, these are
+ /// settings rather than captured traffic and survive clearing the inspection database.
+ var responseTransformRules: [ResponseTransformRule] {
+ get { Self.decodeResponseTransformRules(UserDefaults.standard.data(forKey: Keys.responseTransformRules)) }
+ set {
+ UserDefaults.standard.set(try? JSONEncoder().encode(newValue), forKey: Keys.responseTransformRules)
+ objectWillChange.send()
+ }
+ }
+
+ var activeResponseTransformRules: Int { responseTransformRules.filter(\.enabled).count }
+
+ nonisolated static func decodeResponseTransformRules(_ data: Data?) -> [ResponseTransformRule] {
+ guard let data else { return [] }
+ return (try? JSONDecoder().decode([ResponseTransformRule].self, from: data)) ?? []
+ }
+
var configuredPort: UInt16 { UInt16(clamping: max(1024, UserDefaults.standard.integer(forKey: Keys.port))) }
func attach(db: TrafficDatabase) {
diff --git a/Flowlight/Inspection/InspectionProxy.swift b/Flowlight/Inspection/InspectionProxy.swift
index 52743c1..3777928 100644
--- a/Flowlight/Inspection/InspectionProxy.swift
+++ b/Flowlight/Inspection/InspectionProxy.swift
@@ -32,6 +32,9 @@ protocol ProxyObserver: AnyObject {
/// The request whose bytes come next left with something taken out of it — a guardrail removing tools an
/// agent is not allowed to use. What is recorded is what the server was sent, which is what happened.
func flow(_ flow: ProxyFlow, guardedBy note: String)
+ /// The upstream response bytes that come next were changed before the client received them. This is deliberately
+ /// distinct from a mock (which never contacted the origin) and a guardrail (which changed the request).
+ func flow(_ flow: ProxyFlow, responseTransformedBy note: String)
}
/// A local HTTP proxy on 127.0.0.1 that can decrypt HTTPS for inspection.
@@ -65,6 +68,9 @@ final class InspectionProxy: @unchecked Sendable {
/// Asked once per connection: is there anything that would want to read, change or answer whole requests on
/// it? Nil means every request on this connection streams untouched, which is what almost all of them do.
var interventions: (_ host: String, _ clientPort: UInt16) -> ((ProxyRequestHead, Data) -> MockGate.Intervention?)? = { _, _ in nil }
+ /// Response transforms are separate from request interventions: their gate observes only bytes actually sent
+ /// upstream and can replace a bounded upstream response before it reaches both the client and recorder.
+ var responseInterventions: (_ flow: ProxyFlow) -> ResponseGate.Intervention? = { _ in nil }
/// Served at http://127.0.0.1:/proxy.pac.
var pacScript: () -> String = { "function FindProxyForURL(url, host) { return \"DIRECT\"; }" }
var onStateChange: (String?) -> Void = { _ in }
@@ -321,10 +327,30 @@ final class InspectionProxy: @unchecked Sendable {
let intervene = interventions(flow.host, flow.clientPort)
let gate = mocks.isEmpty && intervene == nil ? nil : MockGate(host: flow.host, rules: mocks)
gate?.intervene = intervene
+ let responseTransform = responseInterventions(flow)
+ let responseGate = responseTransform.map { ResponseGate(transform: $0.transform, shouldTransform: $0.shouldTransform) }
let fromClient: (Data) -> Data = { [weak self] data in
guard let self else { return data }
- guard let gate else { self.observer?.flow(flow, clientSent: data); return data }
- return self.apply(gate.clientSent(data), flow: flow, client: client)
+ guard let gate else {
+ self.observer?.flow(flow, clientSent: data)
+ responseGate?.clientSent(data)
+ return data
+ }
+ let onward = self.apply(gate.clientSent(data), flow: flow, client: client)
+ responseGate?.clientSent(onward)
+ return onward
+ }
+ let fromServer: (Data) -> Data = { [weak self] data in
+ guard let self else { return data }
+ guard let responseGate else { self.observer?.flow(flow, serverSent: data); return data }
+ let actions = responseGate.serverSent(data)
+ var onward = Data()
+ for (bytes, note) in actions {
+ if let note { self.observer?.flow(flow, responseTransformedBy: note) }
+ self.observer?.flow(flow, serverSent: bytes)
+ onward.append(bytes)
+ }
+ return onward
}
var ended = false
let finish: (String?) -> Void = { [weak self] note in
@@ -343,7 +369,7 @@ final class InspectionProxy: @unchecked Sendable {
if !onward.isEmpty { upstream.send(content: onward, completion: .idempotent) }
}
self.pump(client, into: upstream, tap: fromClient) { finish(nil) }
- self.pump(upstream, into: client, tap: { self.observer?.flow(flow, serverSent: $0); return $0 }) { finish(nil) }
+ self.pump(upstream, into: client, tap: fromServer) { finish(nil) }
case .failed(let error):
finish("Couldn't reach \(flow.host): \(error.localizedDescription)")
case .waiting(let error):
diff --git a/Flowlight/Inspection/InspectionRecorder.swift b/Flowlight/Inspection/InspectionRecorder.swift
index 6bdb43f..333d0d9 100644
--- a/Flowlight/Inspection/InspectionRecorder.swift
+++ b/Flowlight/Inspection/InspectionRecorder.swift
@@ -44,6 +44,9 @@ struct HTTPExchange: Identifiable, Equatable, Sendable {
/// What a guardrail took out of this request before it left. Its own field for the same reason: the exchange
/// is real and was really sent, but it is not quite what the agent wrote, and that has to be readable.
var guardrail: String?
+ /// The rule that changed a real upstream response before the client received it. A mock is intentionally not
+ /// reused here: a transformed exchange really reached its origin.
+ var responseTransform: String? = nil
var url: String {
let defaultPort = (scheme == "https" && port == 443) || (scheme == "http" && port == 80)
@@ -108,7 +111,7 @@ enum SocketOwner {
/// them to the process (and agent) behind the connection, reads tool calls, redacts credentials, and hands each
/// finished exchange to `onExchange`.
final class InspectionRecorder: ProxyObserver, @unchecked Sendable {
- private struct Pending { var head: HTTPHead; var body: HTTPBody; var started: Date; var mock: String?; var guardrail: String? }
+ private struct Pending { var head: HTTPHead; var body: HTTPBody; var started: Date; var mock: String?; var guardrail: String?; var responseTransform: String? }
private final class FlowState {
let request: HTTPStreamParser
@@ -117,6 +120,9 @@ final class InspectionRecorder: ProxyObserver, @unchecked Sendable {
/// Set by the proxy just before the bytes that complete a request it answers itself.
var nextMock: String?
var nextGuardrail: String?
+ /// Marked by the proxy immediately before the transformed response is fed here. The oldest pending request
+ /// is the response being delivered, including on a pipelined keep-alive connection.
+ var nextResponseTransform: String?
var owner: Owner?
let ownerReady = DispatchSemaphore(value: 0)
init(limit: Int) {
@@ -156,13 +162,15 @@ final class InspectionRecorder: ProxyObserver, @unchecked Sendable {
state.request.onHead = { response.requestMethods.append($0.method) }
state.request.onMessage = { [weak state] head, body in
state?.queue.append(Pending(head: head, body: body, started: Date(), mock: state?.nextMock,
- guardrail: state?.nextGuardrail))
+ guardrail: state?.nextGuardrail, responseTransform: nil))
state?.nextMock = nil
state?.nextGuardrail = nil
}
state.response.onMessage = { [weak self, weak state] head, body in
guard let self, let state, !state.queue.isEmpty else { return }
- let request = state.queue.removeFirst()
+ var request = state.queue.removeFirst()
+ request.responseTransform = state.nextResponseTransform
+ state.nextResponseTransform = nil
self.emit(flow: flow, state: state, request: request, responseHead: head, responseBody: body, note: nil)
}
flows[flow.id] = state
@@ -187,6 +195,7 @@ final class InspectionRecorder: ProxyObserver, @unchecked Sendable {
func flow(_ flow: ProxyFlow, serverSent data: Data) { flows[flow.id]?.response.feed(data) }
func flow(_ flow: ProxyFlow, mockedBy rule: String) { flows[flow.id]?.nextMock = rule }
func flow(_ flow: ProxyFlow, guardedBy note: String) { flows[flow.id]?.nextGuardrail = note }
+ func flow(_ flow: ProxyFlow, responseTransformedBy note: String) { flows[flow.id]?.nextResponseTransform = note }
func flowEnded(_ flow: ProxyFlow, note: String?) {
guard let state = flows.removeValue(forKey: flow.id) else { return }
@@ -247,7 +256,7 @@ final class InspectionRecorder: ProxyObserver, @unchecked Sendable {
contentType: request.head.value("Content-Type") ?? responseHead?.value("Content-Type") ?? "", pid: owner.pid, bundleID: owner.bundleID, appName: owner.appName,
agent: owner.agent, agentName: owner.agentName, mcpServer: owner.mcpServer, toolCalls: calls,
toolResults: results, mcp: mcp, llm: llm, note: notes.isEmpty ? nil : notes.joined(separator: " "),
- mockRule: request.mock, guardrail: request.guardrail)
+ mockRule: request.mock, guardrail: request.guardrail, responseTransform: request.responseTransform)
onExchange(exchange)
}
}
diff --git a/Flowlight/Inspection/MockRule.swift b/Flowlight/Inspection/MockRule.swift
index 572ab11..ac38686 100644
--- a/Flowlight/Inspection/MockRule.swift
+++ b/Flowlight/Inspection/MockRule.swift
@@ -39,8 +39,10 @@ struct MockRule: Codable, Equatable, Identifiable, Sendable {
/// A rule prefilled from a recorded exchange, so "mock this" starts from the request that was actually made.
init(mocking exchange: HTTPExchange) {
+ let endpoint = exchange.path.split(separator: "?").first.map(String.init) ?? "/"
+ name = "Mock \(exchange.method.uppercased()) \(exchange.host)\(endpoint)"
host = exchange.host
- path = exchange.path.split(separator: "?").first.map(String.init) ?? "/"
+ path = endpoint
method = exchange.method
status = 500
body = #"{"error": "mocked by Flowlight"}"#
diff --git a/Flowlight/Inspection/ResponseGate.swift b/Flowlight/Inspection/ResponseGate.swift
new file mode 100644
index 0000000..fc72f8a
--- /dev/null
+++ b/Flowlight/Inspection/ResponseGate.swift
@@ -0,0 +1,161 @@
+import Foundation
+
+/// Frames the upstream side of an inspected HTTP/1.1 connection. Only a bounded, fixed-length identity response is
+/// held; all other responses retain their original bytes and streaming behaviour.
+final class ResponseGate {
+ struct Context: Sendable { var head: HTTPHead }
+ struct Replacement: Sendable { var body: Data; var note: String }
+ struct Intervention {
+ var transform: (Context, HTTPHead, Data) -> Replacement?
+ var shouldTransform: (Context) -> Bool
+ }
+
+ static let holdLimit = 4 * 1024 * 1024
+ var transform: (Context, HTTPHead, Data) -> Replacement?
+ /// Evaluated as soon as a final response is paired to its request, so unrelated fixed responses preserve their
+ /// streaming behaviour instead of being buffered merely because another endpoint on this host has a rule.
+ var shouldTransform: (Context) -> Bool
+
+ private let request = HTTPStreamParser(direction: .request, limit: 64 * 1024)
+ private var requests: [Context] = []
+ private var buffer = Data()
+ private var state = State.head
+ private var responseHead: HTTPHead?
+ private var current: Context?
+ private var held = Data()
+
+ private enum State { case head, fixed(Int), passthroughFixed(Int), opaque }
+
+ init(transform: @escaping (Context, HTTPHead, Data) -> Replacement?, shouldTransform: @escaping (Context) -> Bool = { _ in true }) {
+ self.transform = transform
+ self.shouldTransform = shouldTransform
+ request.onHead = { [weak self] in self?.requests.append(Context(head: $0)) }
+ }
+
+ func clientSent(_ data: Data) { request.feed(data) }
+
+ /// Returns client-visible bytes plus the transform provenance associated with replacement bytes.
+ func serverSent(_ data: Data) -> [(Data, String?)] {
+ guard !data.isEmpty else { return [] }
+ if case .opaque = state { return [(data, nil)] }
+ buffer.append(data)
+ var out: [(Data, String?)] = []
+ while step(&out) {}
+ return out
+ }
+
+ private func step(_ out: inout [(Data, String?)]) -> Bool {
+ switch state {
+ case .opaque:
+ if !buffer.isEmpty { out.append((take(buffer.count), nil)) }
+ return false
+ case .head:
+ guard let end = buffer.range(of: Data("\r\n\r\n".utf8)) else {
+ if buffer.count > 64 * 1024 { out.append((take(buffer.count), nil)); state = .opaque; return true }
+ return false
+ }
+ let rawHead = take(buffer.distance(from: buffer.startIndex, to: end.upperBound))
+ let head = Self.parseHead(rawHead)
+ guard let head else { out.append((rawHead, nil)); state = .opaque; return true }
+ let status = head.status ?? 0
+ // 1xx aside from switching protocols does not consume a request context.
+ if (100..<200).contains(status), status != 101 { out.append((rawHead, nil)); return true }
+ guard let context = requests.isEmpty ? nil : requests.removeFirst() else {
+ out.append((rawHead, nil)); state = .opaque; return true
+ }
+ current = context; responseHead = head
+ if status == 101 || context.head.method == "CONNECT" && (200..<300).contains(status) {
+ out.append((rawHead, nil)); state = .opaque; return true
+ }
+ if context.head.method == "HEAD" || status == 204 || status == 304 {
+ out.append((rawHead, nil)); clear(); return true
+ }
+ guard shouldTransform(context) else {
+ out.append((rawHead, nil))
+ // Transfer-Encoding defines framing when present. A conflicting Content-Length must not make an
+ // untouched chunked response look fixed-length, or later bytes could be mistaken for a new head.
+ if head.value("Transfer-Encoding") != nil {
+ state = .opaque
+ } else if let length = head.value("Content-Length").flatMap(Int.init), length >= 0 {
+ state = .passthroughFixed(length)
+ } else {
+ state = .opaque
+ }
+ return true
+ }
+ let encoding = head.value("Content-Encoding")?.trimmingCharacters(in: .whitespaces).lowercased() ?? "identity"
+ if head.value("Transfer-Encoding")?.lowercased().contains("chunked") == true {
+ // Chunk boundaries and trailers are part of the original wire representation. Keeping them intact
+ // means this connection cannot safely return to HTTP framing after an unsupported streamed reply.
+ out.append((rawHead, nil)); state = .opaque; return true
+ }
+ guard let length = head.value("Content-Length").flatMap(Int.init), length >= 0 else {
+ out.append((rawHead, nil)); state = .opaque; return true
+ }
+ guard length <= Self.holdLimit, encoding == "identity" else {
+ // A known fixed-size response can still be skipped without losing later keep-alive responses.
+ out.append((rawHead, nil)); state = .passthroughFixed(length); return true
+ }
+ // A candidate must be held as a whole. If no rule ultimately changes it, the same exact bytes are emitted.
+ held = rawHead
+ state = .fixed(length)
+ if length == 0 { finish(&out) }
+ return true
+ case .fixed(let remaining):
+ guard !buffer.isEmpty else { return false }
+ let count = min(remaining, buffer.count)
+ held.append(take(count))
+ if remaining == count { finish(&out) } else { state = .fixed(remaining - count) }
+ return true
+ case .passthroughFixed(let remaining):
+ guard !buffer.isEmpty else { return false }
+ let count = min(remaining, buffer.count)
+ out.append((take(count), nil))
+ if remaining == count { clear() } else { state = .passthroughFixed(remaining - count) }
+ return true
+ }
+ }
+
+ private func finish(_ out: inout [(Data, String?)]) {
+ guard let head = responseHead, let context = current,
+ let separator = held.range(of: Data("\r\n\r\n".utf8)) else {
+ out.append((held, nil)); clear(); return
+ }
+ let body = Data(held[separator.upperBound...])
+ if let replacement = transform(context, head, body) {
+ out.append((Self.reframe(head: head, body: replacement.body), replacement.note))
+ } else {
+ out.append((held, nil))
+ }
+ clear()
+ }
+
+ private func clear() { state = .head; responseHead = nil; current = nil; held.removeAll(keepingCapacity: true) }
+ private func take(_ n: Int) -> Data { let end = buffer.index(buffer.startIndex, offsetBy: n); let value = Data(buffer[.. HTTPHead? {
+ guard let text = String(data: raw, encoding: .utf8) else { return nil }
+ var lines = text.components(separatedBy: "\r\n")
+ guard let start = lines.first, start.hasPrefix("HTTP/") else { return nil }
+ lines.removeFirst()
+ return HTTPHead(startLine: start, headers: lines.compactMap { line in
+ guard let colon = line.firstIndex(of: ":") else { return nil }
+ return HTTPHeader(name: String(line[.. Data {
+ var lines = [head.startLine]
+ for header in head.headers {
+ let name = MockRule.headerSafe(header.name)
+ guard !name.isEmpty else { continue }
+ let lower = name.lowercased()
+ guard lower != "content-length" && lower != "transfer-encoding" && lower != "connection" else { continue }
+ lines.append("\(name): \(MockRule.headerSafe(header.value))")
+ }
+ lines.append("Content-Length: \(body.count)")
+ var out = Data((lines.joined(separator: "\r\n") + "\r\n\r\n").utf8)
+ out.append(body)
+ return out
+ }
+}
diff --git a/Flowlight/Inspection/ResponseScriptRunner.swift b/Flowlight/Inspection/ResponseScriptRunner.swift
new file mode 100644
index 0000000..02f0493
--- /dev/null
+++ b/Flowlight/Inspection/ResponseScriptRunner.swift
@@ -0,0 +1,108 @@
+import Foundation
+
+/// Runs one response script out of process. Any problem is deliberately indistinguishable from "no edit": the proxy
+/// sends the server's original response rather than inventing an error on behalf of a diagnostic rule.
+final class ResponseScriptRunner: @unchecked Sendable {
+ static let shared = ResponseScriptRunner()
+ static let maxScriptBytes = 128 * 1024
+ static let maxPayloadBytes = ResponseGate.holdLimit
+ static let timeout: TimeInterval = 0.25
+
+ struct Result: Sendable { var body: Data; var note: String }
+
+ private final class OutputCapture: @unchecked Sendable {
+ private let lock = NSLock()
+ private var value = Data()
+
+ func set(_ data: Data) { lock.lock(); value = data; lock.unlock() }
+ func get() -> Data { lock.lock(); defer { lock.unlock() }; return value }
+ }
+
+ private init() {}
+
+ func transform(rules: [ResponseTransformRule], request: ResponseTransformRequest, responseHead: HTTPHead, responseBody: Data) -> Result? {
+ guard responseBody.count <= Self.maxPayloadBytes,
+ let representation = ResponseRepresentation.detect(headers: responseHead.headers, body: responseBody) else { return nil }
+ var body = responseBody
+ var names: [String] = []
+ for rule in rules {
+ guard rule.script.lengthOfBytes(using: .utf8) <= Self.maxScriptBytes,
+ let transformed = run(rule: rule, request: request, representation: representation, body: body),
+ transformed.count <= Self.maxPayloadBytes else { continue }
+ body = transformed
+ names.append(rule.title)
+ }
+ guard !names.isEmpty, body != responseBody else { return nil }
+ return Result(body: body, note: names.joined(separator: " · "))
+ }
+
+ private func run(rule: ResponseTransformRule, request: ResponseTransformRequest, representation: ResponseRepresentation, body: Data) -> Data? {
+ let input: [String: Any] = [
+ "script": rule.script,
+ "method": request.method,
+ "url": request.url,
+ "requestHeaders": request.headers,
+ "representation": representation.rawValue,
+ "response": representation == .json
+ ? ((try? JSONSerialization.jsonObject(with: body)) as Any)
+ : (String(data: body, encoding: .utf8) as Any),
+ ]
+ guard JSONSerialization.isValidJSONObject(input),
+ let encoded = try? JSONSerialization.data(withJSONObject: input),
+ encoded.count <= Self.maxPayloadBytes else { return nil }
+ guard let executable = helperURL() else { return nil }
+ let process = Process()
+ process.executableURL = executable
+ process.arguments = []
+ process.environment = [:]
+ let stdin = Pipe(), stdout = Pipe()
+ process.standardInput = stdin
+ process.standardOutput = stdout
+ process.standardError = FileHandle.nullDevice
+ let done = DispatchSemaphore(value: 0)
+ let outputDone = DispatchSemaphore(value: 0)
+ let output = OutputCapture()
+ // Drain stdout while the helper runs. Waiting for it to exit before reading would deadlock when a valid
+ // response grows beyond a pipe buffer (the helper permits up to the transform hold limit).
+ DispatchQueue.global(qos: .userInitiated).async {
+ output.set(stdout.fileHandleForReading.readDataToEndOfFile())
+ outputDone.signal()
+ }
+ process.terminationHandler = { _ in done.signal() }
+ do { try process.run() } catch { return nil }
+ stdin.fileHandleForWriting.write(encoded)
+ try? stdin.fileHandleForWriting.close()
+ guard done.wait(timeout: .now() + Self.timeout) == .success else {
+ process.terminate()
+ if done.wait(timeout: .now() + 0.1) == .timedOut, process.isRunning {
+ kill(process.processIdentifier, SIGKILL)
+ _ = done.wait(timeout: .now() + 0.1)
+ }
+ _ = outputDone.wait(timeout: .now() + 0.1)
+ return nil
+ }
+ guard outputDone.wait(timeout: .now() + 0.1) == .success,
+ process.terminationStatus == 0 else { return nil }
+ let captured = output.get()
+ guard captured.count <= Self.maxPayloadBytes,
+ let object = try? JSONSerialization.jsonObject(with: captured) as? [String: Any],
+ object["representation"] as? String == representation.rawValue,
+ let value = object["response"] else { return nil }
+ switch representation {
+ case .json:
+ guard JSONSerialization.isValidJSONObject([value]),
+ let data = try? JSONSerialization.data(withJSONObject: value) else { return nil }
+ return data
+ case .text:
+ guard let text = value as? String else { return nil }
+ return Data(text.utf8)
+ }
+ }
+
+ private func helperURL() -> URL? {
+ guard let executable = Bundle.main.executableURL else { return nil }
+ let url = executable.deletingLastPathComponent().deletingLastPathComponent()
+ .appendingPathComponent("Library/Helpers/FlowlightResponseScriptHelper")
+ return FileManager.default.isExecutableFile(atPath: url.path) ? url : nil
+ }
+}
diff --git a/Flowlight/Inspection/ResponseTransformRule.swift b/Flowlight/Inspection/ResponseTransformRule.swift
new file mode 100644
index 0000000..3fcd330
--- /dev/null
+++ b/Flowlight/Inspection/ResponseTransformRule.swift
@@ -0,0 +1,187 @@
+import Foundation
+
+/// A script that changes an eligible upstream response after the real server answered it.
+///
+/// Unlike `MockRule`, this never answers a request locally: the matching request reaches its origin first. The
+/// response-side gate only asks a script runner to change complete, bounded HTTP/1.1 replies it can safely reframe.
+struct ResponseTransformRule: Codable, Equatable, Identifiable, Sendable {
+ var id = UUID()
+ var enabled = true
+ var name = ""
+ /// `api.example.com` matches that host alone; `*.example.com` matches the domain and its subdomains.
+ var host = ""
+ /// A glob where `*` stands for any run of characters. A pattern containing `?` also matches the query string.
+ var path = "*"
+ /// Empty (or `ANY`) matches any method.
+ var method = ""
+ /// A synchronous JavaScript function named `modifyResponse(args)`.
+ var script = ""
+
+ static let methods = MockRule.methods
+
+ var title: String {
+ name.isEmpty ? "\(method.isEmpty ? "ANY" : method.uppercased()) \(host)\(path)" : name
+ }
+
+ /// A complete, editable starting rule for an inspected exchange. Captured payload bytes are deliberately not
+ /// copied into a setting: the script receives the response that is live when the rule later runs.
+ init(transforming exchange: HTTPExchange, representation: ResponseRepresentation) {
+ let endpoint = exchange.path.split(separator: "?").first.map(String.init) ?? "/"
+ name = "Transform \(exchange.method.uppercased()) \(exchange.host)\(endpoint)"
+ host = exchange.host
+ path = endpoint
+ method = exchange.method
+ script = Self.template(for: representation)
+ }
+
+ init(id: UUID = UUID(), enabled: Bool = true, name: String = "", host: String = "", path: String = "*",
+ method: String = "", script: String = "") {
+ self.id = id; self.enabled = enabled; self.name = name; self.host = host; self.path = path
+ self.method = method; self.script = script
+ }
+
+ enum CodingKeys: String, CodingKey { case id, enabled, name, host, path, method, script }
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
+ enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
+ name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
+ host = try c.decodeIfPresent(String.self, forKey: .host) ?? ""
+ path = try c.decodeIfPresent(String.self, forKey: .path) ?? "*"
+ method = try c.decodeIfPresent(String.self, forKey: .method) ?? ""
+ script = try c.decodeIfPresent(String.self, forKey: .script) ?? ""
+ }
+
+ static func template(for representation: ResponseRepresentation) -> String {
+ switch representation {
+ case .json:
+ return """
+ function modifyResponse(args) {
+ const { method, url, responseJSON, requestHeaders } = args;
+
+ // Change responseJSON in place, or return a replacement JSON value.
+ return responseJSON;
+ }
+ """
+ case .text:
+ return """
+ function modifyResponse(args) {
+ const { method, url, responseText, requestHeaders } = args;
+
+ // Return the text the app should receive.
+ return responseText;
+ }
+ """
+ }
+ }
+}
+
+/// The only two body representations the first response-transform release accepts.
+enum ResponseRepresentation: String, Codable, Equatable, Sendable {
+ case json
+ case text
+
+ static func detect(headers: [HTTPHeader], body: Data) -> ResponseRepresentation? {
+ let type = headers.first { $0.name.caseInsensitiveCompare("Content-Type") == .orderedSame }?.value
+ .split(separator: ";", maxSplits: 1).first.map { $0.trimmingCharacters(in: .whitespaces).lowercased() } ?? ""
+ if type == "application/json" || type.hasSuffix("+json") {
+ return (try? JSONSerialization.jsonObject(with: body)) == nil ? nil : .json
+ }
+ guard String(data: body, encoding: .utf8) != nil else { return nil }
+ return .text
+ }
+}
+
+/// The small, credential-free request context a response script can inspect.
+struct ResponseTransformRequest: Equatable, Sendable {
+ var method: String
+ var url: String
+ var headers: [String: String]
+
+ init(head: HTTPHead, host: String, scheme: String, port: Int) {
+ method = head.method
+ let defaultPort = (scheme == "https" && port == 443) || (scheme == "http" && port == 80)
+ url = "\(scheme)://\(host)\(defaultPort ? "" : ":\(port)")\(Self.redactedTarget(head.target))"
+ var safe: [String: String] = [:]
+ for header in head.headers where !HeaderRedaction.isSecret(header.name) {
+ // HTTP permits repeated headers. A script gets the last public value, which is predictable and avoids
+ // inventing a delimiter that could alter the meaning of a value.
+ safe[header.name] = header.value
+ }
+ headers = safe
+ }
+
+ /// Query values often carry credentials too. Keep their names and replace only values whose names are known to
+ /// be credentials, so scripts can branch on public routing parameters without receiving a token by accident.
+ private static func redactedTarget(_ target: String) -> String {
+ guard var parts = URLComponents(string: "http://flowlight.invalid\(target)") else { return target }
+ if let items = parts.queryItems {
+ parts.queryItems = items.map { item in
+ HeaderRedaction.isSecret(item.name) ? URLQueryItem(name: item.name, value: "•••") : item
+ }
+ }
+ let rendered = parts.percentEncodedPath + (parts.percentEncodedQuery.map { "?\($0)" } ?? "")
+ return rendered.isEmpty ? "/" : rendered
+ }
+}
+
+enum ResponseTransformRules {
+ static func matching(_ rules: [ResponseTransformRule], host: String) -> [ResponseTransformRule] {
+ rules.filter { $0.enabled && GlobMatch.host($0.host, host) }
+ }
+
+ static func applicable(_ rules: [ResponseTransformRule], host: String, method: String, path: String) -> [ResponseTransformRule] {
+ rules.filter {
+ $0.enabled && GlobMatch.host($0.host, host) && GlobMatch.method($0.method, method) && GlobMatch.path($0.path, path)
+ }
+ }
+}
+
+/// The selected exchange plus only display-safe data for a prefilled editor. It intentionally contains no retained
+/// request/response body: transform rules are settings and must not silently outlive captured traffic.
+struct ResponseTransformDraft: Identifiable, Equatable, Sendable {
+ var id: UUID { rule.id }
+ var rule: ResponseTransformRule
+ var method: String
+ var url: String
+ var status: Int?
+ var requestHeaders: [HTTPHeader]
+ var responseHeaders: [HTTPHeader]
+ var representation: ResponseRepresentation?
+ var unavailableReason: String?
+
+ init(exchange: HTTPExchange, responseBody: Data?) {
+ let representation: ResponseRepresentation?
+ let unavailableReason: String?
+ if exchange.note != nil || exchange.mockRule != nil {
+ representation = nil
+ unavailableReason = L("Only a real upstream response can be modified.")
+ } else if exchange.responseTruncated {
+ representation = nil
+ unavailableReason = L("This response was only partially retained, so Flowlight cannot safely prepare a transform.")
+ } else if exchange.responseHeaders.contains(where: { $0.name.caseInsensitiveCompare("Transfer-Encoding") == .orderedSame }) {
+ representation = nil
+ unavailableReason = L("This response was streamed or chunked, so Flowlight cannot safely transform it.")
+ } else if let encoding = exchange.responseHeaders.first(where: { $0.name.caseInsensitiveCompare("Content-Encoding") == .orderedSame })?.value,
+ !encoding.trimmingCharacters(in: .whitespaces).isEmpty,
+ encoding.trimmingCharacters(in: .whitespaces).caseInsensitiveCompare("identity") != .orderedSame {
+ representation = nil
+ unavailableReason = L("This response was compressed, so Flowlight cannot safely transform it.")
+ } else if let responseBody {
+ representation = ResponseRepresentation.detect(headers: exchange.responseHeaders, body: responseBody)
+ unavailableReason = representation == nil ? L("This response is not a complete JSON or UTF-8 text response.") : nil
+ } else {
+ representation = nil
+ unavailableReason = L("The captured response body is unavailable.")
+ }
+ self.representation = representation
+ self.unavailableReason = unavailableReason
+ rule = ResponseTransformRule(transforming: exchange, representation: representation ?? .text)
+ method = exchange.method
+ url = exchange.url
+ status = exchange.status
+ requestHeaders = exchange.requestHeaders
+ responseHeaders = exchange.responseHeaders
+ }
+}
diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift
index eb6d9c0..6c9ab6f 100644
--- a/Flowlight/Storage/TrafficDatabase.swift
+++ b/Flowlight/Storage/TrafficDatabase.swift
@@ -405,6 +405,10 @@ final class TrafficDatabase: @unchecked Sendable {
if !exchangeColumns.contains("guardrail") {
try conn.execute("ALTER TABLE http_exchanges ADD COLUMN guardrail TEXT NOT NULL DEFAULT ''")
}
+ // Unlike a mock, this names a response the origin really sent before Flowlight changed it for the client.
+ if !exchangeColumns.contains("response_transform") {
+ try conn.execute("ALTER TABLE http_exchanges ADD COLUMN response_transform TEXT NOT NULL DEFAULT ''")
+ }
// Added with rules: the rule list itself, and every connection a rule decided. The decisions are their own
// table rather than only alerts — an alert is a thing that happened once, and a violations feed has to be
// answerable by rule ("what has this one refused?") as well as by time.
@@ -1007,8 +1011,8 @@ final class TrafficDatabase: @unchecked Sendable {
try conn.run("""
INSERT INTO http_exchanges (ts, duration, scheme, host, port, method, path, status, req_headers, req_body, req_size,
req_truncated, resp_headers, resp_body, resp_size, resp_truncated, content_type, pid, bundle_id, app_name, agent,
- agent_name, mcp_server, tool_calls, note, tool_results, mcp, llm, mock_rule, guardrail)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ agent_name, mcp_server, tool_calls, note, tool_results, mcp, llm, mock_rule, guardrail, response_transform)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", [.double(e.started.timeIntervalSince1970), .double(e.duration), .text(e.scheme), .text(e.host), .int(Int64(e.port)),
.text(e.method), .text(e.path), e.status.map { .int(Int64($0)) } ?? .null,
.text(json(e.requestHeaders)), .blob(e.requestBody), .int(Int64(e.requestSize)), .int(e.requestTruncated ? 1 : 0),
@@ -1016,7 +1020,7 @@ final class TrafficDatabase: @unchecked Sendable {
.text(e.contentType), .int(Int64(e.pid)), .text(e.bundleID), .text(e.appName), .text(e.agent ?? ""),
.text(e.agentName ?? ""), .text(e.mcpServer ?? ""), .text(e.toolCalls.isEmpty ? "" : json(e.toolCalls)), .text(e.note ?? ""),
.text(e.toolResults.isEmpty ? "" : json(e.toolResults)), .text(e.mcp.isEmpty ? "" : json(e.mcp)),
- .text(e.llm.map(json) ?? ""), .text(e.mockRule ?? ""), .text(e.guardrail ?? "")])
+ .text(e.llm.map(json) ?? ""), .text(e.mockRule ?? ""), .text(e.guardrail ?? ""), .text(e.responseTransform ?? "")])
let id = try conn.query("SELECT last_insert_rowid()", map: { $0.int(0) }).first ?? 0
if enqueueLocalRisk, LocalRiskSettings.enabled {
var stored = e
@@ -1229,7 +1233,7 @@ final class TrafficDatabase: @unchecked Sendable {
return try conn.query("""
SELECT id, ts, duration, scheme, host, port, method, path, status, req_headers, req_size, req_truncated, resp_headers,
resp_size, resp_truncated, content_type, pid, bundle_id, app_name, agent, agent_name, mcp_server, tool_calls, note,
- tool_results, mcp, llm, mock_rule, guardrail
+ tool_results, mcp, llm, mock_rule, guardrail, response_transform
FROM http_exchanges WHERE ts >= ? AND (? = '' OR host LIKE ? OR path LIKE ? OR app_name LIKE ? OR agent_name LIKE ? OR tool_calls LIKE ?
OR mcp LIKE ? OR CAST(req_body AS TEXT) LIKE ? OR CAST(resp_body AS TEXT) LIKE ?)\(focusClause)\(scopeClause)
ORDER BY ts DESC LIMIT ?
@@ -1250,7 +1254,8 @@ final class TrafficDatabase: @unchecked Sendable {
toolResults: results.isEmpty ? [] : ((try? decoder.decode([ToolResult].self, from: Data(results.utf8))) ?? []),
mcp: mcp.isEmpty ? [] : ((try? decoder.decode([MCPActivity].self, from: Data(mcp.utf8))) ?? []),
llm: llm.isEmpty ? nil : try? decoder.decode(LLMFacts.self, from: Data(llm.utf8)),
- note: row.text(23).nilIfEmpty, mockRule: row.text(27).nilIfEmpty, guardrail: row.text(28).nilIfEmpty)
+ note: row.text(23).nilIfEmpty, mockRule: row.text(27).nilIfEmpty, guardrail: row.text(28).nilIfEmpty,
+ responseTransform: row.text(29).nilIfEmpty)
}
}
diff --git a/Flowlight/UI/InspectView.swift b/Flowlight/UI/InspectView.swift
index 047f42b..008d275 100644
--- a/Flowlight/UI/InspectView.swift
+++ b/Flowlight/UI/InspectView.swift
@@ -29,14 +29,17 @@ private struct InspectContent: View {
@State private var showSetup = false
/// A rule prefilled from a recorded exchange, waiting in the editor.
@State private var mockDraft: MockRule?
+ /// 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.
@State private var openMocks = false
+ @State private var openResponseTransforms = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
if (!inspection.enabled || showSetup) && !DemoData.isEnabled {
ScrollView {
- InspectionSetup(inspection: inspection, openMocks: openMocks, done: { showSetup = false })
+ InspectionSetup(inspection: inspection, openMocks: openMocks, openResponseTransforms: openResponseTransforms, done: { showSetup = false })
.padding(.bottom, 20)
}
} else {
@@ -97,6 +100,9 @@ private struct InspectContent: View {
.sheet(item: $mockDraft) { draft in
MockRuleEditor(rule: draft, isNew: true) { inspection.mockRules.append($0) }
}
+ .sheet(item: $responseTransformDraft) { draft in
+ ResponseTransformRuleEditor(rule: draft.rule, isNew: true, draft: draft) { inspection.responseTransformRules.append($0) }
+ }
.task(id: LoadKey(version: monitor.inspectionVersion, localRiskVersion: monitor.localRiskVersion, search: search, window: window,
enabled: inspection.enabled, potentialHarmOnly: potentialHarmOnly, focus: focus.scope, app: scopeApp?.id, host: scopeHost)) {
// Coalesce bursts of new exchanges.
@@ -110,6 +116,22 @@ private struct InspectContent: View {
var potentialHarmOnly: Bool; var focus: FocusScope; var app: String?; var host: String?
}
+ private func canModifyResponse(_ exchange: HTTPExchange) -> Bool {
+ !DemoData.isEnabled && exchange.note == nil && exchange.mockRule == nil && exchange.status != nil && !exchange.responseTruncated
+ }
+
+ private func prepareResponseTransform(from exchange: HTTPExchange) {
+ Task {
+ let body: Data?
+ if let id = exchange.id {
+ body = try? await monitor.read { try $0.exchangeBodies(id: id)?.response }
+ } else {
+ body = nil
+ }
+ responseTransformDraft = ResponseTransformDraft(exchange: exchange, responseBody: body)
+ }
+ }
+
private func load() async {
let since = Date().addingTimeInterval(-window.interval), term = search, scope = focus.scope
let app = scopeApp?.id, host = scopeHost
@@ -236,6 +258,11 @@ private struct InspectContent: View {
.foregroundStyle(.teal).labelStyle(.titleAndIcon)
.help(L("A guardrail changed this request before it left: %@", guardrail))
}
+ if let transform = e.responseTransform {
+ Label(L("Response modified"), systemImage: "arrow.left.arrow.right").font(.caption2.bold())
+ .foregroundStyle(.purple).labelStyle(.titleAndIcon)
+ .help(L("%@ answered, then Flowlight changed the response using %@ before the app received it", e.host, transform))
+ }
}
Text(e.path).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle)
}
@@ -268,6 +295,9 @@ private struct InspectContent: View {
.contextMenu(forSelectionType: HTTPExchange.ID.self) { ids in
if !DemoData.isEnabled, let id = ids.first, let e = exchanges.first(where: { $0.id == id }), e.note == nil {
Button(L("Mock This Endpoint…")) { mockDraft = MockRule(mocking: e) }
+ if canModifyResponse(e) {
+ Button(L("Modify This Response…")) { prepareResponseTransform(from: e) }
+ }
Divider()
// Inspect is the one screen that can offer a URL rather than a whole host, because it is the one
// place the path was ever visible.
@@ -286,7 +316,8 @@ private struct InspectContent: View {
@ViewBuilder private var detail: some View {
if let selected = exchanges.first(where: { $0.id == selection }) {
ExchangeDetail(exchange: selected, assessment: selected.id.flatMap { assessments[$0] }, cause: selected.id.flatMap { links[$0] },
- results: results, highlight: search, mockThis: DemoData.isEnabled ? nil : { mockDraft = MockRule(mocking: selected) })
+ results: results, highlight: search, mockThis: DemoData.isEnabled ? nil : { mockDraft = MockRule(mocking: selected) },
+ modifyResponse: canModifyResponse(selected) ? { prepareResponseTransform(from: selected) } : nil)
.id(selected.id)
} else {
ContentUnavailableView(L("Select a request"), systemImage: "doc.text.magnifyingglass")
@@ -298,12 +329,14 @@ private struct InspectContent: View {
private struct InspectionSetup: View {
@ObservedObject var inspection: InspectionController
var openMocks = false
+ var openResponseTransforms = false
var done: () -> Void
@State private var newPattern = ""
@State private var confirmRemove = false
@State private var showAdvanced = false
@State private var showMocks = false
@State private var showRewrites = false
+ @State private var showResponseTransforms = false
@State private var confirmTurnOn = false
@Environment(\.openURL) private var openURL
@@ -442,6 +475,19 @@ private struct InspectionSetup: View {
}
}
}
+
+ DisclosureGroup(isExpanded: $showResponseTransforms) {
+ ResponseTransformRulesSection(inspection: inspection).padding(.top, 10)
+ } label: {
+ HStack(spacing: 8) {
+ Text(L("Modify responses")).font(.headline)
+ if inspection.activeResponseTransformRules > 0 {
+ Label(inspection.activeResponseTransformRules == 1 ? L("1 on") : L("%lld on", inspection.activeResponseTransformRules),
+ systemImage: "arrow.left.arrow.right")
+ .font(.caption.bold()).foregroundStyle(FL.tool)
+ }
+ }
+ }
}
.measured(Measure.prose)
.confirmationDialog(L("macOS will ask you twice"), isPresented: $confirmTurnOn) {
@@ -455,7 +501,11 @@ private struct InspectionSetup: View {
} message: {
Text(L("Turns inspection off, removes the certificate and its trust setting, and deletes every recorded request."))
}
- .onAppear { inspection.refreshStatus(); showMocks = showMocks || openMocks }
+ .onAppear {
+ inspection.refreshStatus()
+ showMocks = showMocks || openMocks
+ showResponseTransforms = showResponseTransforms || openResponseTransforms
+ }
}
private func ready(_ text: String, ok: Bool) -> some View {
@@ -482,6 +532,8 @@ private struct ExchangeDetail: View {
var highlight: String = ""
/// Starts a mock rule from this request, when mocking is available (it isn't in demo mode).
var mockThis: (() -> Void)?
+ /// Starts a response transform from this real upstream exchange.
+ var modifyResponse: (() -> Void)?
@State private var bodies: (request: Data, response: Data)?
@State private var tab = 0
@State private var headersOpen: Bool?
@@ -497,6 +549,10 @@ private struct ExchangeDetail: View {
Button(L("Mock This…"), action: mockThis).buttonStyle(.link).font(.caption)
.help(L("Answer this endpoint from Flowlight instead of letting the request through"))
}
+ if let modifyResponse {
+ Button(L("Modify This Response…"), action: modifyResponse).buttonStyle(.link).font(.caption)
+ .help(L("Let the request reach %@, then change its eligible response before this app receives it", exchange.host))
+ }
}
}
if LocalRiskSettings.isActive {
@@ -518,6 +574,11 @@ private struct ExchangeDetail: View {
guardrail, exchange.host), systemImage: "shield.lefthalf.filled")
.foregroundStyle(.teal).fixedSize(horizontal: false, vertical: true)
}
+ if let transform = exchange.responseTransform {
+ Label(L("%@ answered this request. Flowlight then changed the response with “%@” before the app received it.",
+ exchange.host, transform), systemImage: "arrow.left.arrow.right")
+ .foregroundStyle(.purple).fixedSize(horizontal: false, vertical: true)
+ }
if let cause {
GroupBox(L("Made by a tool call")) {
VStack(alignment: .leading, spacing: 2) {
diff --git a/Flowlight/UI/ResponseTransformRulesView.swift b/Flowlight/UI/ResponseTransformRulesView.swift
new file mode 100644
index 0000000..b86e4d1
--- /dev/null
+++ b/Flowlight/UI/ResponseTransformRulesView.swift
@@ -0,0 +1,135 @@
+import SwiftUI
+
+/// Scripts that change a real upstream reply after the origin answered it. They intentionally live beside mocks and
+/// outbound rewrites: all three require decrypted HTTP, but their effects are different enough to be visible apart.
+struct ResponseTransformRulesSection: View {
+ @ObservedObject var inspection: InspectionController
+ @State private var editing: ResponseTransformRule?
+ @State private var isNew = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text(L("Let the request reach its server, then change an eligible JSON or text response before the app receives it."))
+ .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
+ Label(L("Only complete, fixed-length, uncompressed HTTP/1.1 responses Flowlight decrypts can be changed. Streaming, chunked and compressed replies pass through unchanged."),
+ systemImage: "info.circle")
+ .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
+ if !inspection.enabled {
+ Label(L("HTTPS inspection is off, so no response can be read or changed yet."), systemImage: "exclamationmark.triangle")
+ .font(.caption).foregroundStyle(FL.warning)
+ }
+ if inspection.responseTransformRules.isEmpty {
+ Text(L("No response rules.")).font(.caption).foregroundStyle(.tertiary)
+ } else {
+ VStack(spacing: 0) {
+ ForEach(Array(inspection.responseTransformRules.enumerated()), id: \.element.id) { index, rule in
+ HStack(spacing: 8) {
+ Toggle("", isOn: Binding(get: { rule.enabled }, set: { enabled in
+ var copy = rule; copy.enabled = enabled
+ inspection.responseTransformRules[index] = copy
+ }))
+ .toggleStyle(.switch).controlSize(.mini).labelsHidden()
+ VStack(alignment: .leading, spacing: 1) {
+ Text(rule.title).lineLimit(1)
+ Text("\(rule.method.isEmpty ? "ANY" : rule.method) \(rule.host)\(rule.path)")
+ .font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
+ }
+ .opacity(rule.enabled ? 1 : 0.5)
+ Spacer()
+ Button { editing = rule; isNew = false } label: { Image(systemName: "pencil") }
+ .buttonStyle(.borderless).accessibilityLabel(L("Edit %@", rule.title))
+ Button(role: .destructive) { inspection.responseTransformRules.removeAll { $0.id == rule.id } } label: {
+ Image(systemName: "trash")
+ }
+ .buttonStyle(.borderless).accessibilityLabel(L("Remove %@", rule.title))
+ }
+ .padding(.horizontal, 8).padding(.vertical, 5)
+ if index < inspection.responseTransformRules.count - 1 { Divider() }
+ }
+ }
+ .padding(.vertical, 2).background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 6))
+ }
+ HStack {
+ Button(L("Add Rule…")) { editing = ResponseTransformRule(host: "", path: "/*", script: ResponseTransformRule.template(for: .json)); isNew = true }
+ Spacer()
+ if inspection.activeResponseTransformRules > 0 {
+ Button(L("Turn All Off")) { inspection.responseTransformRules = inspection.responseTransformRules.map { var rule = $0; rule.enabled = false; return rule } }
+ }
+ }
+ }
+ .sheet(item: $editing) { rule in
+ ResponseTransformRuleEditor(rule: rule, isNew: isNew) { saved in
+ if let index = inspection.responseTransformRules.firstIndex(where: { $0.id == saved.id }) {
+ inspection.responseTransformRules[index] = saved
+ } else {
+ inspection.responseTransformRules.append(saved)
+ }
+ }
+ }
+ }
+}
+
+/// The captured context is visible but not copied into the persistent rule: it is an aid for authoring a transform,
+/// not an additional route by which retained traffic can escape its inspection budget.
+struct ResponseTransformRuleEditor: View {
+ @State var rule: ResponseTransformRule
+ var isNew: Bool
+ var draft: ResponseTransformDraft?
+ var save: (ResponseTransformRule) -> Void
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ Text(isNew ? L("New response transform") : L("Edit response transform")).font(.title3.bold())
+ Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 10, verticalSpacing: 8) {
+ GridRow { Text(L("Name")).gridColumnAlignment(.trailing).foregroundStyle(.secondary); TextField(L("Transform response"), text: $rule.name) }
+ GridRow { Text(L("Host")).gridColumnAlignment(.trailing).foregroundStyle(.secondary); TextField(L("api.example.com"), text: $rule.host) }
+ GridRow { Text(L("Path")).gridColumnAlignment(.trailing).foregroundStyle(.secondary); TextField(L("/v1/*"), text: $rule.path) }
+ GridRow {
+ Text(L("Method")).gridColumnAlignment(.trailing).foregroundStyle(.secondary)
+ Picker("", selection: Binding(get: { rule.method.isEmpty ? "ANY" : rule.method.uppercased() }, set: { rule.method = $0 == "ANY" ? "" : $0 })) {
+ ForEach(ResponseTransformRule.methods, id: \.self) { Text($0).tag($0) }
+ }.labelsHidden().frame(width: 130)
+ }
+ }
+ if let draft {
+ GroupBox(L("Captured request context")) {
+ VStack(alignment: .leading, spacing: 3) {
+ Text("\(draft.method) \(draft.url)").font(.caption.monospaced()).textSelection(.enabled)
+ Text(draft.status.map { L("Upstream status: %lld", $0) } ?? L("No captured upstream status."))
+ .font(.caption).foregroundStyle(.secondary)
+ if !draft.requestHeaders.isEmpty {
+ Text(draft.requestHeaders.map { "\($0.name): \($0.value)" }.joined(separator: "\n"))
+ .font(.caption.monospaced()).textSelection(.enabled).lineLimit(5).foregroundStyle(.secondary)
+ }
+ if let unavailable = draft.unavailableReason {
+ Label(unavailable, systemImage: "info.circle").font(.caption).foregroundStyle(FL.warning)
+ }
+ }.frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ Text(L("JavaScript")).font(.caption.bold()).foregroundStyle(.secondary)
+ TextEditor(text: $rule.script).font(.caption.monospaced()).frame(height: 250).border(.quaternary)
+ Text(L("Define synchronous modifyResponse(args). responseJSON is available for JSON; responseText is available for UTF-8 text. Credential headers are never supplied."))
+ .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
+ HStack {
+ Button(L("Cancel"), role: .cancel) { dismiss() }.keyboardShortcut(.cancelAction)
+ Spacer()
+ Button(isNew ? L("Add Rule") : L("Save")) { save(cleaned()); dismiss() }
+ .keyboardShortcut(.defaultAction)
+ .disabled(draft?.unavailableReason != nil || rule.host.trimmingCharacters(in: .whitespaces).isEmpty || rule.script.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+ }
+ .padding(20).frame(width: 640)
+ }
+
+ private func cleaned() -> ResponseTransformRule {
+ var copy = rule
+ copy.name = copy.name.trimmingCharacters(in: .whitespaces)
+ copy.host = copy.host.trimmingCharacters(in: .whitespaces).lowercased()
+ copy.path = copy.path.trimmingCharacters(in: .whitespaces)
+ if copy.path.isEmpty { copy.path = "*" }
+ copy.method = copy.method.trimmingCharacters(in: .whitespaces).uppercased()
+ return copy
+ }
+}
diff --git a/FlowlightResponseScriptHelper/main.swift b/FlowlightResponseScriptHelper/main.swift
new file mode 100644
index 0000000..1c56bc0
--- /dev/null
+++ b/FlowlightResponseScriptHelper/main.swift
@@ -0,0 +1,61 @@
+import Foundation
+import JavaScriptCore
+
+private let maxInputBytes = 4 * 1024 * 1024
+private let maxScriptBytes = 128 * 1024
+private let maxOutputBytes = 4 * 1024 * 1024
+
+func fail(_ message: String) -> Never {
+ FileHandle.standardError.write(Data((message + "\n").utf8))
+ exit(1)
+}
+
+let input = FileHandle.standardInput.readDataToEndOfFile()
+guard input.count <= maxInputBytes,
+ let object = try? JSONSerialization.jsonObject(with: input) as? [String: Any],
+ let script = object["script"] as? String,
+ script.lengthOfBytes(using: .utf8) <= maxScriptBytes,
+ let method = object["method"] as? String,
+ let url = object["url"] as? String,
+ let headers = object["requestHeaders"] as? [String: String],
+ let representation = object["representation"] as? String,
+ representation == "json" || representation == "text",
+ let response = object["response"] else { fail("invalid input") }
+
+let context = JSContext()!
+context.exceptionHandler = { _, _ in }
+guard context.evaluateScript(script) != nil,
+ context.exception == nil,
+ let function = context.objectForKeyedSubscript("modifyResponse"),
+ !function.isUndefined,
+ function.isObject else { fail("modifyResponse was not defined") }
+
+let args = context.evaluateScript("({})")!
+args.setValue(method, forProperty: "method")
+args.setValue(url, forProperty: "url")
+args.setValue(headers, forProperty: "requestHeaders")
+if representation == "json" {
+ args.setValue(response, forProperty: "responseJSON")
+} else {
+ guard let text = response as? String else { fail("invalid text") }
+ args.setValue(text, forProperty: "responseText")
+}
+guard let result = function.call(withArguments: [args]), context.exception == nil,
+ !result.isUndefined, !result.isNull else { fail("script failed") }
+
+let value = result.toObject()
+switch representation {
+case "json":
+ guard let value, JSONSerialization.isValidJSONObject([value]),
+ let data = try? JSONSerialization.data(withJSONObject: value), data.count <= maxOutputBytes else { fail("invalid JSON result") }
+ let output: [String: Any] = ["representation": "json", "response": value]
+ guard let encoded = try? JSONSerialization.data(withJSONObject: output), encoded.count <= maxOutputBytes else { fail("output too large") }
+ FileHandle.standardOutput.write(encoded)
+case "text":
+ guard let text = value as? String, text.lengthOfBytes(using: .utf8) <= maxOutputBytes else { fail("invalid text result") }
+ let output: [String: Any] = ["representation": "text", "response": text]
+ guard let encoded = try? JSONSerialization.data(withJSONObject: output), encoded.count <= maxOutputBytes else { fail("output too large") }
+ FileHandle.standardOutput.write(encoded)
+default:
+ fail("invalid representation")
+}
diff --git a/FlowlightTests/MockResponseTests.swift b/FlowlightTests/MockResponseTests.swift
index c3abb9b..80a2953 100644
--- a/FlowlightTests/MockResponseTests.swift
+++ b/FlowlightTests/MockResponseTests.swift
@@ -75,6 +75,7 @@ final class MockRuleMatchingTests: XCTestCase {
requestTruncated: false, responseHeaders: [], responseBody: Data(), responseSize: 0, responseTruncated: false,
contentType: "", pid: 1, bundleID: "b", appName: "a", agent: nil, agentName: nil, mcpServer: nil, toolCalls: [])
let prefilled = MockRule(mocking: exchange)
+ XCTAssertEqual(prefilled.name, "Mock POST api.example.com/v1/items")
XCTAssertEqual(prefilled.host, "api.example.com")
XCTAssertEqual(prefilled.path, "/v1/items") // the query is dropped: it rarely identifies the endpoint
XCTAssertEqual(prefilled.method, "POST")
diff --git a/FlowlightTests/ResponseTransformTests.swift b/FlowlightTests/ResponseTransformTests.swift
new file mode 100644
index 0000000..26d0bac
--- /dev/null
+++ b/FlowlightTests/ResponseTransformTests.swift
@@ -0,0 +1,306 @@
+import Network
+import XCTest
+@testable import Flowlight
+
+final class ResponseTransformRuleTests: XCTestCase {
+ private func exchange(path: String = "/v1/items?token=secret") -> HTTPExchange {
+ HTTPExchange(
+ id: 1, started: Date(), duration: 0, scheme: "https", host: "api.example.com", port: 443,
+ method: "POST", path: path, status: 200,
+ requestHeaders: [
+ HTTPHeader(name: "Authorization", value: "••• (12 chars)"),
+ HTTPHeader(name: "X-Request-ID", value: "public"),
+ ],
+ requestBody: Data(), requestSize: 0, requestTruncated: false,
+ responseHeaders: [HTTPHeader(name: "Content-Type", value: "application/json")],
+ responseBody: Data(), responseSize: 0, responseTruncated: false, contentType: "application/json",
+ pid: 1, bundleID: "test", appName: "Test", agent: nil, agentName: nil, mcpServer: nil, toolCalls: []
+ )
+ }
+
+ func testPrefillPopulatesMatcherAndJSONTemplateWithoutCapturingBody() {
+ let draft = ResponseTransformDraft(exchange: exchange(), responseBody: Data(#"{"ok":true}"#.utf8))
+ XCTAssertEqual(draft.rule.name, "Transform POST api.example.com/v1/items")
+ XCTAssertEqual(draft.rule.host, "api.example.com")
+ XCTAssertEqual(draft.rule.path, "/v1/items")
+ XCTAssertEqual(draft.rule.method, "POST")
+ XCTAssertTrue(draft.rule.script.contains("responseJSON"))
+ XCTAssertNil(draft.unavailableReason)
+ XCTAssertEqual(draft.representation, .json)
+ }
+
+ func testTextPrefillAndUnavailableBodyStillPopulateEveryMatcherField() {
+ var textExchange = exchange(path: "/status")
+ textExchange.responseHeaders = [HTTPHeader(name: "Content-Type", value: "text/plain")]
+ let text = ResponseTransformDraft(exchange: textExchange, responseBody: Data("ready".utf8))
+ XCTAssertEqual(text.representation, .text)
+ XCTAssertTrue(text.rule.script.contains("responseText"))
+
+ let unavailable = ResponseTransformDraft(exchange: exchange(), responseBody: nil)
+ XCTAssertEqual(unavailable.rule.host, "api.example.com")
+ XCTAssertEqual(unavailable.rule.path, "/v1/items")
+ XCTAssertEqual(unavailable.rule.method, "POST")
+ XCTAssertNotNil(unavailable.unavailableReason)
+
+ var chunked = exchange()
+ chunked.responseHeaders = [HTTPHeader(name: "Transfer-Encoding", value: "chunked")]
+ XCTAssertEqual(ResponseTransformDraft(exchange: chunked, responseBody: Data("text".utf8)).unavailableReason,
+ L("This response was streamed or chunked, so Flowlight cannot safely transform it."))
+ }
+
+ func testRulesAreOrderedAndRequestContextExcludesCredentials() {
+ let first = ResponseTransformRule(name: "First", host: "api.example.com", path: "/v1/*", method: "POST", script: "x")
+ let second = ResponseTransformRule(name: "Second", host: "*.example.com", path: "*", script: "x")
+ XCTAssertEqual(ResponseTransformRules.applicable([first, second], host: "api.example.com", method: "POST", path: "/v1/items").map(\.name), ["First", "Second"])
+
+ let request = ResponseTransformRequest(
+ head: HTTPHead(startLine: "GET /v1/items?token=abc&lang=en HTTP/1.1", headers: [
+ HTTPHeader(name: "Authorization", value: "Bearer private"),
+ HTTPHeader(name: "X-API-Key", value: "private"),
+ HTTPHeader(name: "Accept", value: "application/json"),
+ ]),
+ host: "api.example.com", scheme: "https", port: 443
+ )
+ XCTAssertEqual(request.headers, ["Accept": "application/json"])
+ XCTAssertTrue(request.url.contains("token=%E2%80%A2%E2%80%A2%E2%80%A2"))
+ XCTAssertTrue(request.url.contains("lang=en"))
+ }
+
+ func testBundledRunnerAppliesJSONAndTextScripts() {
+ let request = ResponseTransformRequest(
+ head: HTTPHead(startLine: "GET /items HTTP/1.1", headers: []), host: "api.example.com", scheme: "https", port: 443
+ )
+ let jsonRule = ResponseTransformRule(host: "api.example.com", script: "function modifyResponse(args) { args.responseJSON.changed = true; return args.responseJSON; }")
+ let jsonHead = HTTPHead(startLine: "HTTP/1.1 200 OK", headers: [HTTPHeader(name: "Content-Type", value: "application/json")])
+ let json = ResponseScriptRunner.shared.transform(rules: [jsonRule], request: request, responseHead: jsonHead, responseBody: Data(#"{"live":true}"#.utf8))
+ let jsonObject = try! XCTUnwrap(json).body
+ XCTAssertEqual(try! JSONSerialization.jsonObject(with: jsonObject) as? [String: Bool], ["live": true, "changed": true])
+
+ let textRule = ResponseTransformRule(host: "api.example.com", script: "function modifyResponse(args) { return args.responseText.toUpperCase(); }")
+ let textHead = HTTPHead(startLine: "HTTP/1.1 200 OK", headers: [HTTPHeader(name: "Content-Type", value: "text/plain")])
+ let text = ResponseScriptRunner.shared.transform(rules: [textRule], request: request, responseHead: textHead, responseBody: Data("ready".utf8))
+ XCTAssertEqual(String(decoding: try! XCTUnwrap(text).body, as: UTF8.self), "READY")
+ }
+
+ func testRunnerWithholdsCredentialsAndFailsOpenForBrokenOrSlowScripts() {
+ let secretRequest = ResponseTransformRequest(
+ head: HTTPHead(startLine: "GET /items?token=secret HTTP/1.1", headers: [
+ HTTPHeader(name: "Authorization", value: "Bearer private"),
+ HTTPHeader(name: "Accept", value: "text/plain"),
+ ]),
+ host: "api.example.com", scheme: "https", port: 443
+ )
+ let textHead = HTTPHead(startLine: "HTTP/1.1 200 OK", headers: [HTTPHeader(name: "Content-Type", value: "text/plain")])
+ let credentialProbe = ResponseTransformRule(host: "api.example.com", script: "function modifyResponse(args) { return args.requestHeaders.Authorization ? 'leaked' : args.url; }")
+ let safe = ResponseScriptRunner.shared.transform(rules: [credentialProbe], request: secretRequest, responseHead: textHead, responseBody: Data("original".utf8))
+ XCTAssertEqual(String(decoding: try! XCTUnwrap(safe).body, as: UTF8.self), "https://api.example.com/items?token=%E2%80%A2%E2%80%A2%E2%80%A2")
+
+ let malformed = ResponseTransformRule(host: "api.example.com", script: "function modifyResponse( {")
+ XCTAssertNil(ResponseScriptRunner.shared.transform(rules: [malformed], request: secretRequest, responseHead: textHead, responseBody: Data("original".utf8)))
+
+ let slow = ResponseTransformRule(host: "api.example.com", script: "function modifyResponse(args) { while (true) {} }")
+ let started = Date()
+ XCTAssertNil(ResponseScriptRunner.shared.transform(rules: [slow], request: secretRequest, responseHead: textHead, responseBody: Data("original".utf8)))
+ XCTAssertLessThan(Date().timeIntervalSince(started), 1, "a slow response script must not stall the proxy")
+ }
+
+ func testPartialRuleDecodesAndResponseRepresentationRequiresValidBody() throws {
+ let rule = try JSONDecoder().decode(ResponseTransformRule.self, from: Data(#"{"host":"api.example.com"}"#.utf8))
+ XCTAssertTrue(rule.enabled)
+ XCTAssertEqual(rule.path, "*")
+ XCTAssertEqual(rule.script, "")
+ XCTAssertEqual(ResponseRepresentation.detect(headers: [HTTPHeader(name: "Content-Type", value: "application/json")], body: Data("not json".utf8)), nil)
+ XCTAssertEqual(ResponseRepresentation.detect(headers: [HTTPHeader(name: "Content-Type", value: "text/plain")], body: Data("text".utf8)), .text)
+ XCTAssertNil(ResponseRepresentation.detect(headers: [], body: Data([0xFF])))
+ }
+}
+
+final class ResponseGateTests: XCTestCase {
+ private func run(_ request: String, _ chunks: [String], transform: @escaping (ResponseGate.Context, HTTPHead, Data) -> ResponseGate.Replacement? = { _, _, _ in nil }) -> (wire: String, notes: [String]) {
+ let gate = ResponseGate(transform: transform)
+ gate.clientSent(Data(request.utf8))
+ var wire = Data(), notes: [String] = []
+ for chunk in chunks {
+ for (bytes, note) in gate.serverSent(Data(chunk.utf8)) {
+ wire.append(bytes)
+ if let note { notes.append(note) }
+ }
+ }
+ return (String(decoding: wire, as: UTF8.self), notes)
+ }
+
+ func testReframesTransformedFixedResponseAndProtectsHeaderBoundaries() {
+ let request = "GET /items HTTP/1.1\r\nHost: api.example.com\r\n\r\n"
+ let response = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 4\r\nConnection: keep-alive\r\nX-Unsafe: fine\r\n\r\nreal"
+ let result = run(request, [String(response.prefix(31)), String(response.dropFirst(31))]) { _, _, body in
+ XCTAssertEqual(String(decoding: body, as: UTF8.self), "real")
+ return ResponseGate.Replacement(body: Data("changed".utf8), note: "edit")
+ }
+ XCTAssertEqual(result.notes, ["edit"])
+ XCTAssertTrue(result.wire.hasPrefix("HTTP/1.1 200 OK\r\n"))
+ XCTAssertTrue(result.wire.contains("Content-Length: 7\r\n"))
+ XCTAssertFalse(result.wire.contains("Connection:"))
+ XCTAssertFalse(result.wire.contains("Content-Length: 4"))
+ XCTAssertTrue(result.wire.hasSuffix("\r\n\r\nchanged"))
+ }
+
+ func testInterimResponseDoesNotConsumeRequestAndPipelinedResponsesStayOrdered() {
+ let first = "GET /first HTTP/1.1\r\nHost: api.example.com\r\n\r\n"
+ let second = "GET /second HTTP/1.1\r\nHost: api.example.com\r\n\r\n"
+ let response = "HTTP/1.1 100 Continue\r\n\r\n" +
+ "HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\na" +
+ "HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\nb"
+ var paths: [String] = []
+ let result = run(first + second, [response]) { context, _, body in
+ paths.append(context.head.target)
+ return ResponseGate.Replacement(body: Data((String(decoding: body, as: UTF8.self).uppercased()).utf8), note: context.head.target)
+ }
+ XCTAssertEqual(paths, ["/first", "/second"])
+ XCTAssertEqual(result.notes, ["/first", "/second"])
+ XCTAssertTrue(result.wire.contains("\r\n\r\nAHTTP/1.1 200 OK"))
+ XCTAssertTrue(result.wire.hasSuffix("\r\n\r\nB"))
+ }
+
+ func testUnmatchedFixedResponseStreamsWithoutHoldingOrRunningScript() {
+ let request = "GET /unmatched HTTP/1.1\r\nHost: api.example.com\r\n\r\n"
+ let head = "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\n"
+ let gate = ResponseGate(transform: { _, _, _ in XCTFail("unmatched response must not run script"); return nil }, shouldTransform: { _ in false })
+ gate.clientSent(Data(request.utf8))
+ XCTAssertEqual(String(decoding: gate.serverSent(Data(head.utf8)).first!.0, as: UTF8.self), head)
+ XCTAssertEqual(String(decoding: gate.serverSent(Data("real".utf8)).first!.0, as: UTF8.self), "real")
+ }
+
+ func testUnmatchedChunkedResponseWithContentLengthRemainsOpaque() {
+ let request = "GET /stream HTTP/1.1\r\nHost: api.example.com\r\n\r\n"
+ let head = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Length: 4\r\n\r\n"
+ let body = "4\r\nreal\r\n0\r\n\r\n"
+ let gate = ResponseGate(transform: { _, _, _ in XCTFail("unmatched response must not run script"); return nil }, shouldTransform: { _ in false })
+ gate.clientSent(Data(request.utf8))
+ XCTAssertEqual(String(decoding: gate.serverSent(Data(head.utf8)).first!.0, as: UTF8.self), head)
+ XCTAssertEqual(String(decoding: gate.serverSent(Data(body.utf8)).first!.0, as: UTF8.self), body)
+ }
+
+ func testChunkedResponsePassesThroughByteForByteWithoutRunningScript() {
+ let request = "GET /events HTTP/1.1\r\nHost: api.example.com\r\n\r\n"
+ let response = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nreal\r\n0\r\nTrailer: x\r\n\r\n"
+ let result = run(request, [response]) { _, _, _ in XCTFail("chunked response must not be held"); return nil }
+ XCTAssertEqual(result.wire, response)
+ XCTAssertTrue(result.notes.isEmpty)
+ }
+
+ func testCompressedFixedResponsePassesThroughThenLaterResponseCanTransform() {
+ let first = "GET /compressed HTTP/1.1\r\nHost: api.example.com\r\n\r\n"
+ let second = "GET /plain HTTP/1.1\r\nHost: api.example.com\r\n\r\n"
+ let response = "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: 4\r\n\r\ngzip" +
+ "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"
+ let result = run(first + second, [response]) { context, _, body in
+ guard context.head.target == "/plain" else { XCTFail("compressed response must not run script"); return nil }
+ XCTAssertEqual(String(decoding: body, as: UTF8.self), "ok")
+ return ResponseGate.Replacement(body: Data("yes".utf8), note: "plain")
+ }
+ XCTAssertTrue(result.wire.hasPrefix("HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: 4\r\n\r\ngzip"))
+ XCTAssertTrue(result.wire.hasSuffix("Content-Length: 3\r\n\r\nyes"))
+ XCTAssertEqual(result.notes, ["plain"])
+ }
+
+ func testHeadAndNoContentResponsesAreNotTransformed() {
+ let head = run("HEAD / HTTP/1.1\r\nHost: api.example.com\r\n\r\n", ["HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n"]) { _, _, _ in XCTFail(); return nil }
+ XCTAssertEqual(head.wire, "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n")
+ let noContent = run("GET / HTTP/1.1\r\nHost: api.example.com\r\n\r\n", ["HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n"]) { _, _, _ in XCTFail(); return nil }
+ XCTAssertEqual(noContent.wire, "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
+ }
+}
+
+final class ResponseTransformProxyTests: XCTestCase {
+ private func startUpstream(reached: @escaping () -> Void) throws -> (NWListener, UInt16) {
+ let queue = DispatchQueue(label: "response-transform-upstream")
+ let upstream = try NWListener(using: .tcp)
+ upstream.newConnectionHandler = { connection in
+ connection.start(queue: queue)
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, _, _ in
+ if let data, !data.isEmpty { reached() }
+ connection.send(content: Data("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 13\r\nConnection: close\r\n\r\n{\"live\":true}".utf8), completion: .contentProcessed { _ in connection.cancel() })
+ }
+ }
+ let ready = expectation(description: "upstream ready")
+ upstream.stateUpdateHandler = { if case .ready = $0 { ready.fulfill() } }
+ upstream.start(queue: queue)
+ wait(for: [ready], timeout: 10)
+ return (upstream, upstream.port!.rawValue)
+ }
+
+ private func curl(_ url: String, proxyPort: UInt16) -> (status: String, body: String) {
+ let task = Process()
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/curl")
+ task.arguments = ["-s", "--max-time", "20", "-x", "http://127.0.0.1:\(proxyPort)", "-w", "\\n%{http_code}", url]
+ let output = Pipe()
+ task.standardOutput = output
+ task.standardError = FileHandle.nullDevice
+ try? task.run()
+ let text = String(decoding: output.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
+ task.waitUntilExit()
+ var parts = text.components(separatedBy: "\n")
+ return (parts.popLast() ?? "", parts.joined(separator: "\n"))
+ }
+
+ func testProxyReachesOriginButClientAndRecorderSeeReplacement() throws {
+ var reached = false
+ let (upstream, upstreamPort) = try startUpstream { reached = true }
+ defer { upstream.cancel() }
+ let recorder = InspectionRecorder()
+ let recorded = expectation(description: "recorded transformed response")
+ var exchange: HTTPExchange?
+ recorder.onExchange = { exchange = $0; recorded.fulfill() }
+ let proxy = InspectionProxy()
+ proxy.observer = recorder
+ proxy.responseInterventions = { _ in
+ ResponseGate.Intervention(
+ transform: { _, _, body in
+ XCTAssertEqual(String(decoding: body, as: UTF8.self), #"{"live":true}"#)
+ return ResponseGate.Replacement(body: Data(#"{"changed":true}"#.utf8), note: "Test response transform")
+ },
+ shouldTransform: { _ in true }
+ )
+ }
+ let ready = expectation(description: "proxy ready")
+ proxy.onStateChange = { _ in if proxy.port != nil { ready.fulfill() } }
+ proxy.start(port: 0)
+ wait(for: [ready], timeout: 10)
+ defer { proxy.stop() }
+
+ let result = curl("http://127.0.0.1:\(upstreamPort)/items", proxyPort: try XCTUnwrap(proxy.port))
+ wait(for: [recorded], timeout: 15)
+ XCTAssertTrue(reached)
+ XCTAssertEqual(result.status, "200")
+ XCTAssertEqual(result.body, #"{"changed":true}"#)
+ XCTAssertEqual(exchange?.responseTransform, "Test response transform")
+ XCTAssertEqual(String(decoding: exchange?.responseBody ?? Data(), as: UTF8.self), #"{"changed":true}"#)
+ }
+}
+
+final class ResponseTransformProvenanceTests: XCTestCase {
+ func testRecorderAndDatabaseKeepResponseTransformSeparateFromMockAndGuardrail() throws {
+ let recorder = InspectionRecorder()
+ let recorded = expectation(description: "recorded")
+ var exchange: HTTPExchange?
+ recorder.onExchange = { exchange = $0; recorded.fulfill() }
+ let flow = ProxyFlow(host: "api.example.com", port: 443, clientPort: 1, inspected: true, scheme: "https")
+ recorder.flowStarted(flow)
+ recorder.flow(flow, clientSent: Data("GET /items HTTP/1.1\r\nHost: api.example.com\r\n\r\n".utf8))
+ recorder.flow(flow, responseTransformedBy: "Rewrite status")
+ recorder.flow(flow, serverSent: Data("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok".utf8))
+ wait(for: [recorded], timeout: 5)
+ XCTAssertEqual(exchange?.responseTransform, "Rewrite status")
+ XCTAssertNil(exchange?.mockRule)
+ XCTAssertNil(exchange?.guardrail)
+
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent("response-transform-\(UUID()).sqlite")
+ defer { try? FileManager.default.removeItem(at: url) }
+ let db = try TrafficDatabase(url: url)
+ try db.insertExchange(try XCTUnwrap(exchange))
+ let stored = try XCTUnwrap(db.exchanges(since: .distantPast).first)
+ XCTAssertEqual(stored.responseTransform, "Rewrite status")
+ XCTAssertNil(stored.mockRule)
+ XCTAssertNil(stored.guardrail)
+ }
+}
diff --git a/docs/404.html b/docs/404.html
index b5e1d24..98ce980 100644
--- a/docs/404.html
+++ b/docs/404.html
@@ -80,7 +80,7 @@ That page isn't here Try the home page ,
© 2026 The Flowlight contributors. Flowlight is free software, released under the
GNU General Public License v3.0 .
-
Version 0.13.4 · Not affiliated with Apple or any AI provider named on this site.
+
Version 0.13.5 · Not affiliated with Apple or any AI provider named on this site.
diff --git a/docs/about/index.html b/docs/about/index.html
index 7fc242c..ecec025 100644
--- a/docs/about/index.html
+++ b/docs/about/index.html
@@ -138,7 +138,7 @@ Thanks
© 2026 The Flowlight contributors. Flowlight is free software, released under the
GNU General Public License v3.0 .
-
Version 0.13.4 · Not affiliated with Apple or any AI provider named on this site.
+
Version 0.13.5 · Not affiliated with Apple or any AI provider named on this site.
diff --git a/docs/de/about/index.html b/docs/de/about/index.html
index 1d6d003..3fdd9b4 100644
--- a/docs/de/about/index.html
+++ b/docs/de/about/index.html
@@ -137,7 +137,7 @@ Dank
© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der GNU General Public License v3.0 .
-
Version 0.13.4 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
+
Version 0.13.5 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
diff --git a/docs/de/docs/index.html b/docs/de/docs/index.html
index 0e63f64..93ab06a 100644
--- a/docs/de/docs/index.html
+++ b/docs/de/docs/index.html
@@ -61,7 +61,7 @@
Dokumentation
Flowlight benutzen
-
Alles vom ersten Start bis zum Feinschliff an den Agentenregeln. Flowlight 0.13.4, macOS 15 oder neuer.
+
Alles vom ersten Start bis zum Feinschliff an den Agentenregeln. Flowlight 0.13.5, macOS 15 oder neuer.
@@ -738,7 +738,7 @@ Grenzen
© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der GNU General Public License v3.0 .
-
Version 0.13.4 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
+
Version 0.13.5 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
diff --git a/docs/de/index.html b/docs/de/index.html
index dc667da..65466b9 100644
--- a/docs/de/index.html
+++ b/docs/de/index.html
@@ -34,7 +34,7 @@
-
+
Zum Inhalt springen
@@ -75,7 +75,7 @@ Sieh, was deine Apps im Netz tun.brew install --cask xinbetween/tap/flowlightKopieren
- v0.13.4 macOS 15+ Universal GPL-3.0 Keine Telemetrie
+ v0.13.5 macOS 15+ Universal GPL-3.0 Keine Telemetrie
@@ -616,7 +616,7 @@ Wisse, was deinen Mac verlässt.
© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der GNU General Public License v3.0 .
-
Version 0.13.4 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
+
Version 0.13.5 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
diff --git a/docs/de/privacy/index.html b/docs/de/privacy/index.html
index a84a434..d7eca7d 100644
--- a/docs/de/privacy/index.html
+++ b/docs/de/privacy/index.html
@@ -173,7 +173,7 @@
© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der GNU General Public License v3.0 .
-
Version 0.13.4 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
+
Version 0.13.5 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
diff --git a/docs/de/threat-model/index.html b/docs/de/threat-model/index.html
index 0b5e52a..3b301da 100644
--- a/docs/de/threat-model/index.html
+++ b/docs/de/threat-model/index.html
@@ -213,7 +213,7 @@ Eine Schwachstelle melden
© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der GNU General Public License v3.0 .
-
Version 0.13.4 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
+
Version 0.13.5 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
diff --git a/docs/docs/index.html b/docs/docs/index.html
index 0afdd26..05083c5 100644
--- a/docs/docs/index.html
+++ b/docs/docs/index.html
@@ -61,7 +61,7 @@
Documentation
Using Flowlight
-
Everything from the first launch to tuning the agent rules. Flowlight 0.13.4, macOS 15 or later.
+
Everything from the first launch to tuning the agent rules. Flowlight 0.13.5, macOS 15 or later.
@@ -739,7 +739,7 @@ Limitations
© 2026 The Flowlight contributors. Flowlight is free software, released under the
GNU General Public License v3.0 .
-
Version 0.13.4 · Not affiliated with Apple or any AI provider named on this site.
+
Version 0.13.5 · Not affiliated with Apple or any AI provider named on this site.
diff --git a/docs/es/about/index.html b/docs/es/about/index.html
index c293da1..3220737 100644
--- a/docs/es/about/index.html
+++ b/docs/es/about/index.html
@@ -137,7 +137,7 @@ Agradecimientos
© 2026 Quienes contribuyen a Flowlight. Flowlight es software libre, publicado bajo la GNU General Public License v3.0 .
-
Versión 0.13.4 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
+
Versión 0.13.5 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
diff --git a/docs/es/docs/index.html b/docs/es/docs/index.html
index 2ef5c6c..adf1abb 100644
--- a/docs/es/docs/index.html
+++ b/docs/es/docs/index.html
@@ -61,7 +61,7 @@
Documentación
Usar Flowlight
-
Todo, desde el primer arranque hasta el ajuste de las reglas de agentes. Flowlight 0.13.4, macOS 15 o posterior.
+
Todo, desde el primer arranque hasta el ajuste de las reglas de agentes. Flowlight 0.13.5, macOS 15 o posterior.
@@ -732,7 +732,7 @@ Limitaciones
© 2026 Quienes contribuyen a Flowlight. Flowlight es software libre, publicado bajo la GNU General Public License v3.0 .
-
Versión 0.13.4 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
+
Versión 0.13.5 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
diff --git a/docs/es/index.html b/docs/es/index.html
index 824d9cf..98683f2 100644
--- a/docs/es/index.html
+++ b/docs/es/index.html
@@ -34,7 +34,7 @@
-
+
Ir al contenido
@@ -75,7 +75,7 @@ Observa la actividad de red de tus apps.
brew install --cask xinbetween/tap/flowlightCopiar
- v0.13.4 macOS 15+ Universal GPL-3.0 Sin telemetría
+ v0.13.5 macOS 15+ Universal GPL-3.0 Sin telemetría
@@ -616,7 +616,7 @@ Ten claro qué sale de tu Mac.
© 2026 Quienes contribuyen a Flowlight. Flowlight es software libre, publicado bajo la GNU General Public License v3.0 .
-
Versión 0.13.4 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
+
Versión 0.13.5 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
diff --git a/docs/es/privacy/index.html b/docs/es/privacy/index.html
index a0a8dcc..80aab56 100644
--- a/docs/es/privacy/index.html
+++ b/docs/es/privacy/index.html
@@ -173,7 +173,7 @@
© 2026 Quienes contribuyen a Flowlight. Flowlight es software libre, publicado bajo la GNU General Public License v3.0 .
-
Versión 0.13.4 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
+
Versión 0.13.5 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
diff --git a/docs/es/threat-model/index.html b/docs/es/threat-model/index.html
index 5006cc0..cb5a427 100644
--- a/docs/es/threat-model/index.html
+++ b/docs/es/threat-model/index.html
@@ -208,7 +208,7 @@ Informar de una vulnerabilidad
© 2026 Quienes contribuyen a Flowlight. Flowlight es software libre, publicado bajo la GNU General Public License v3.0 .
-
Versión 0.13.4 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
+
Versión 0.13.5 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
diff --git a/docs/fr/about/index.html b/docs/fr/about/index.html
index b3dcadd..a565128 100644
--- a/docs/fr/about/index.html
+++ b/docs/fr/about/index.html
@@ -137,7 +137,7 @@ Remerciements
© 2026 Les contributeurs de Flowlight. Flowlight est un logiciel libre, publié sous la GNU General Public License v3.0 .
-
Version 0.13.4 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
+
Version 0.13.5 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
diff --git a/docs/fr/docs/index.html b/docs/fr/docs/index.html
index 418c4a6..1d824ed 100644
--- a/docs/fr/docs/index.html
+++ b/docs/fr/docs/index.html
@@ -61,7 +61,7 @@
Documentation
Utiliser Flowlight
-
Tout, du premier lancement au réglage des règles des agents. Flowlight 0.13.4, macOS 15 ou version ultérieure.
+
Tout, du premier lancement au réglage des règles des agents. Flowlight 0.13.5, macOS 15 ou version ultérieure.
@@ -738,7 +738,7 @@ Limites
© 2026 Les contributeurs de Flowlight. Flowlight est un logiciel libre, publié sous la GNU General Public License v3.0 .
-
Version 0.13.4 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
+
Version 0.13.5 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
diff --git a/docs/fr/index.html b/docs/fr/index.html
index 58f0972..543eb79 100644
--- a/docs/fr/index.html
+++ b/docs/fr/index.html
@@ -34,7 +34,7 @@
-
+
Aller au contenu
@@ -75,7 +75,7 @@ Voyez l’activité réseau de vos apps.
brew install --cask xinbetween/tap/flowlightCopier
- v0.13.4 macOS 15+ Universel GPL-3.0 Sans télémétrie
+ v0.13.5 macOS 15+ Universel GPL-3.0 Sans télémétrie
@@ -616,7 +616,7 @@ Sachez ce qui quitte votre Mac.
© 2026 Les contributeurs de Flowlight. Flowlight est un logiciel libre, publié sous la GNU General Public License v3.0 .
-
Version 0.13.4 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
+
Version 0.13.5 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
diff --git a/docs/fr/privacy/index.html b/docs/fr/privacy/index.html
index fe4557f..6a1371c 100644
--- a/docs/fr/privacy/index.html
+++ b/docs/fr/privacy/index.html
@@ -173,7 +173,7 @@
© 2026 Les contributeurs de Flowlight. Flowlight est un logiciel libre, publié sous la GNU General Public License v3.0 .
-
Version 0.13.4 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
+
Version 0.13.5 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
diff --git a/docs/fr/threat-model/index.html b/docs/fr/threat-model/index.html
index c0c52bd..fc509f4 100644
--- a/docs/fr/threat-model/index.html
+++ b/docs/fr/threat-model/index.html
@@ -211,7 +211,7 @@ Signaler une vulnérabilité
© 2026 Les contributeurs de Flowlight. Flowlight est un logiciel libre, publié sous la GNU General Public License v3.0 .
-
Version 0.13.4 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
+
Version 0.13.5 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
diff --git a/docs/index.html b/docs/index.html
index c9f280f..60ad383 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -34,7 +34,7 @@
-
+
Skip to content
@@ -75,7 +75,7 @@ See your apps' network activity.brew install --cask xinbetween/tap/flowlightCopy
- v0.13.4 macOS 15+ Universal GPL-3.0 No telemetry
+ v0.13.5 macOS 15+ Universal GPL-3.0 No telemetry
@@ -625,7 +625,7 @@ Know what leaves your Mac.
© 2026 The Flowlight contributors. Flowlight is free software, released under the
GNU General Public License v3.0 .
-
Version 0.13.4 · Not affiliated with Apple or any AI provider named on this site.
+
Version 0.13.5 · Not affiliated with Apple or any AI provider named on this site.
diff --git a/docs/it/about/index.html b/docs/it/about/index.html
index c77d433..96b255b 100644
--- a/docs/it/about/index.html
+++ b/docs/it/about/index.html
@@ -137,7 +137,7 @@ Ringraziamenti
© 2026 Chi contribuisce a Flowlight. Flowlight è software libero, distribuito sotto la GNU General Public License v3.0 .
-
Versione 0.13.4 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
+
Versione 0.13.5 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
diff --git a/docs/it/docs/index.html b/docs/it/docs/index.html
index c01342a..a17338d 100644
--- a/docs/it/docs/index.html
+++ b/docs/it/docs/index.html
@@ -61,7 +61,7 @@
Documentazione
Usare Flowlight
-
Tutto, dal primo avvio alla messa a punto delle regole per gli agenti. Flowlight 0.13.4, macOS 15 o successivo.
+
Tutto, dal primo avvio alla messa a punto delle regole per gli agenti. Flowlight 0.13.5, macOS 15 o successivo.
@@ -736,7 +736,7 @@ Limitazioni
© 2026 Chi contribuisce a Flowlight. Flowlight è software libero, distribuito sotto la GNU General Public License v3.0 .
-
Versione 0.13.4 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
+
Versione 0.13.5 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
diff --git a/docs/it/index.html b/docs/it/index.html
index 30186c0..30d70fc 100644
--- a/docs/it/index.html
+++ b/docs/it/index.html
@@ -34,7 +34,7 @@
-
+
Vai al contenuto
@@ -75,7 +75,7 @@ Vedi la rete delle tue app.Ca
Metti una stella su GitHub
brew install --cask xinbetween/tap/flowlightCopia
- v0.13.4 macOS 15+ Universale GPL-3.0 Nessuna telemetria
+ v0.13.5 macOS 15+ Universale GPL-3.0 Nessuna telemetria
@@ -616,7 +616,7 @@ Sappi che cosa esce dal tuo Mac.
© 2026 Chi contribuisce a Flowlight. Flowlight è software libero, distribuito sotto la GNU General Public License v3.0 .
-
Versione 0.13.4 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
+
Versione 0.13.5 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
diff --git a/docs/it/privacy/index.html b/docs/it/privacy/index.html
index 4ccdd6f..ed25593 100644
--- a/docs/it/privacy/index.html
+++ b/docs/it/privacy/index.html
@@ -173,7 +173,7 @@
© 2026 Chi contribuisce a Flowlight. Flowlight è software libero, distribuito sotto la GNU General Public License v3.0 .
-
Versione 0.13.4 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
+
Versione 0.13.5 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
diff --git a/docs/it/threat-model/index.html b/docs/it/threat-model/index.html
index 6a2fc3b..61a74bc 100644
--- a/docs/it/threat-model/index.html
+++ b/docs/it/threat-model/index.html
@@ -209,7 +209,7 @@ Segnalare una vulnerabilità
© 2026 Chi contribuisce a Flowlight. Flowlight è software libero, distribuito sotto la GNU General Public License v3.0 .
-
Versione 0.13.4 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
+
Versione 0.13.5 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
diff --git a/docs/ja/about/index.html b/docs/ja/about/index.html
index 83ed34b..00cc521 100644
--- a/docs/ja/about/index.html
+++ b/docs/ja/about/index.html
@@ -138,7 +138,7 @@ 謝辞
© 2026 Flowlight コントリビューター。Flowlight は GNU General Public License v3.0 のもとで公開されている自由ソフトウェアです。
-
バージョン 0.13.4 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
+
バージョン 0.13.5 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
diff --git a/docs/ja/docs/index.html b/docs/ja/docs/index.html
index aab8b3c..4906521 100644
--- a/docs/ja/docs/index.html
+++ b/docs/ja/docs/index.html
@@ -61,7 +61,7 @@
ドキュメント
Flowlight の使い方
-
初回起動からエージェントのルールの調整まで、すべてここに。Flowlight 0.13.4、macOS 15 以降。
+
初回起動からエージェントのルールの調整まで、すべてここに。Flowlight 0.13.5、macOS 15 以降。
@@ -686,7 +686,7 @@ 制限事項
© 2026 Flowlight コントリビューター。Flowlight は GNU General Public License v3.0 のもとで公開されている自由ソフトウェアです。
-
バージョン 0.13.4 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
+
バージョン 0.13.5 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
diff --git a/docs/ja/index.html b/docs/ja/index.html
index 319eb8f..b383e17 100644
--- a/docs/ja/index.html
+++ b/docs/ja/index.html
@@ -34,7 +34,7 @@
-
+
本文へスキップ
@@ -75,7 +75,7 @@ アプリの通信が見える。brew install --cask xinbetween/tap/flowlightコピー
- v0.13.4 macOS 15+ ユニバーサル GPL-3.0 テレメトリなし
+ v0.13.5 macOS 15+ ユニバーサル GPL-3.0 テレメトリなし
@@ -615,7 +615,7 @@ Mac から何が出ていくのかを知る。
© 2026 Flowlight コントリビューター。Flowlight は GNU General Public License v3.0 のもとで公開されている自由ソフトウェアです。
-
バージョン 0.13.4 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
+
バージョン 0.13.5 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
diff --git a/docs/ja/privacy/index.html b/docs/ja/privacy/index.html
index 096aeb2..cec5185 100644
--- a/docs/ja/privacy/index.html
+++ b/docs/ja/privacy/index.html
@@ -171,7 +171,7 @@
© 2026 Flowlight コントリビューター。Flowlight は GNU General Public License v3.0 のもとで公開されている自由ソフトウェアです。
-
バージョン 0.13.4 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
+
バージョン 0.13.5 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
diff --git a/docs/ja/threat-model/index.html b/docs/ja/threat-model/index.html
index 18ac79d..34f614d 100644
--- a/docs/ja/threat-model/index.html
+++ b/docs/ja/threat-model/index.html
@@ -210,7 +210,7 @@ 脆弱性を報告する
© 2026 Flowlight コントリビューター。Flowlight は GNU General Public License v3.0 のもとで公開されている自由ソフトウェアです。
-
バージョン 0.13.4 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
+
バージョン 0.13.5 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
diff --git a/docs/ko/about/index.html b/docs/ko/about/index.html
index 576ddf4..3e4dbef 100644
--- a/docs/ko/about/index.html
+++ b/docs/ko/about/index.html
@@ -138,7 +138,7 @@ 감사
© 2026 Flowlight 기여자들. Flowlight는 GNU General Public License v3.0 아래 배포되는 자유 소프트웨어입니다.
-
버전 0.13.4 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
+
버전 0.13.5 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
diff --git a/docs/ko/docs/index.html b/docs/ko/docs/index.html
index d1891c8..cbddf16 100644
--- a/docs/ko/docs/index.html
+++ b/docs/ko/docs/index.html
@@ -61,7 +61,7 @@
문서
Flowlight 사용하기
-
첫 실행부터 에이전트 규칙 조정까지 전부. Flowlight 0.13.4, macOS 15 이상.
+
첫 실행부터 에이전트 규칙 조정까지 전부. Flowlight 0.13.5, macOS 15 이상.
@@ -728,7 +728,7 @@ 한계
© 2026 Flowlight 기여자들. Flowlight는 GNU General Public License v3.0 아래 배포되는 자유 소프트웨어입니다.
-
버전 0.13.4 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
+
버전 0.13.5 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
diff --git a/docs/ko/index.html b/docs/ko/index.html
index c4bf333..de01a33 100644
--- a/docs/ko/index.html
+++ b/docs/ko/index.html
@@ -34,7 +34,7 @@
-
+
본문으로 이동
@@ -75,7 +75,7 @@ 앱이 무엇을 하는지 봅니다.
brew install --cask xinbetween/tap/flowlight복사
- v0.13.4 macOS 15+ 유니버설 GPL-3.0 텔레메트리 없음
+ v0.13.5 macOS 15+ 유니버설 GPL-3.0 텔레메트리 없음
@@ -616,7 +616,7 @@ 내 Mac에서 무엇이 나가는지 아세요.
© 2026 Flowlight 기여자들. Flowlight는 GNU General Public License v3.0 아래 배포되는 자유 소프트웨어입니다.
-
버전 0.13.4 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
+
버전 0.13.5 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
diff --git a/docs/ko/privacy/index.html b/docs/ko/privacy/index.html
index 7caffc9..8355063 100644
--- a/docs/ko/privacy/index.html
+++ b/docs/ko/privacy/index.html
@@ -174,7 +174,7 @@
© 2026 Flowlight 기여자들. Flowlight는 GNU General Public License v3.0 아래 배포되는 자유 소프트웨어입니다.
-
버전 0.13.4 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
+
버전 0.13.5 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
diff --git a/docs/ko/threat-model/index.html b/docs/ko/threat-model/index.html
index a3b3c45..6654d51 100644
--- a/docs/ko/threat-model/index.html
+++ b/docs/ko/threat-model/index.html
@@ -208,7 +208,7 @@ 취약점 신고하기
© 2026 Flowlight 기여자들. Flowlight는 GNU General Public License v3.0 아래 배포되는 자유 소프트웨어입니다.
-
버전 0.13.4 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
+
버전 0.13.5 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index a4369f2..627a6f2 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -62,7 +62,7 @@ Documentation
Using Flowlight
- Everything from the first launch to tuning the agent rules. Flowlight 0.13.4, macOS 15 or later.
+ Everything from the first launch to tuning the agent rules. Flowlight 0.13.5, macOS 15 or later.
On this page
@@ -847,7 +847,7 @@ Understand your AI agents.
brew install --cask xinbetween/tap/flowlightCopy
- v0.13.4macOS 15+UniversalGPL-3.0No telemetry
+ v0.13.5macOS 15+UniversalGPL-3.0No telemetry
connectionslive
@@ -1413,8 +1413,24 @@ Releases
Downloads, checksums and full notes for each version are on GitHub Releases.
+ 0.13.5
+October 6, 2026Latest
+
+ Modify a real response before the app receives it. Create a response-transform rule from
+ Inspect, then use synchronous JavaScript to change eligible JSON or UTF-8 text responses after their origin
+ server answers. The request still reaches the origin; Flowlight clearly records when a rule changed the
+ client-visible response.
+
+ Safe, scoped scripting. Scripts receive the current method, URL, public request headers and
+ response value only. Credential headers and secret query values are withheld. Streaming, chunked, compressed,
+ oversized and unsupported responses pass through byte-for-byte, and any script error or timeout fails open.
+
+ Start from the exact endpoint. Both response transforms and static mocks now prefill a useful
+ rule name, host, path and method from the selected request. Response transforms also choose a JSON or text
+ starter function and show redacted captured context without retaining its bodies in settings.
+
0.13.4
-October 5, 2026Latest
+October 5, 2026
Mock requests and responses separately. The Mock This Endpoint editor now has Request
and Response tabs: match by host, path and method in one place, then configure the local status, delay,
diff --git a/docs/llms.txt b/docs/llms.txt
index 88aef63..76945da 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -1,6 +1,6 @@
# Flowlight
-> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.13.4.
+> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.13.5.
- [Download Flowlight.dmg](https://github.com/xinbetween/flowlight/releases/latest/download/Flowlight.dmg)
- [Source code](https://github.com/xinbetween/flowlight)
diff --git a/docs/privacy/index.html b/docs/privacy/index.html
index ec381c8..d80de16 100644
--- a/docs/privacy/index.html
+++ b/docs/privacy/index.html
@@ -180,7 +180,7 @@
© 2026 The Flowlight contributors. Flowlight is free software, released under the
GNU General Public License v3.0 .
-
Version 0.13.4 · Not affiliated with Apple or any AI provider named on this site.
+
Version 0.13.5 · Not affiliated with Apple or any AI provider named on this site.
diff --git a/docs/pt-PT/about/index.html b/docs/pt-PT/about/index.html
index eedc325..a700f0b 100644
--- a/docs/pt-PT/about/index.html
+++ b/docs/pt-PT/about/index.html
@@ -137,7 +137,7 @@ Agradecimentos
© 2026 Quem contribui para o Flowlight. O Flowlight é software livre, publicado sob a GNU General Public License v3.0 .
-
Versão 0.13.4 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
+
Versão 0.13.5 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
diff --git a/docs/pt-PT/docs/index.html b/docs/pt-PT/docs/index.html
index 86a3e10..338616c 100644
--- a/docs/pt-PT/docs/index.html
+++ b/docs/pt-PT/docs/index.html
@@ -61,7 +61,7 @@
Documentação
Usar o Flowlight
-
Tudo, da primeira abertura ao ajuste das regras dos agentes. Flowlight 0.13.4, macOS 15 ou posterior.
+
Tudo, da primeira abertura ao ajuste das regras dos agentes. Flowlight 0.13.5, macOS 15 ou posterior.
@@ -733,7 +733,7 @@ Limitações
© 2026 Quem contribui para o Flowlight. O Flowlight é software livre, publicado sob a GNU General Public License v3.0 .
-
Versão 0.13.4 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
+
Versão 0.13.5 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
diff --git a/docs/pt-PT/index.html b/docs/pt-PT/index.html
index 3677a00..8842a88 100644
--- a/docs/pt-PT/index.html
+++ b/docs/pt-PT/index.html
@@ -34,7 +34,7 @@
-
+
Ir para o conteúdo
@@ -75,7 +75,7 @@ Veja a atividade de rede das suas apps.
brew install --cask xinbetween/tap/flowlightCopiar
- v0.13.4 macOS 15+ Universal GPL-3.0 Sem telemetria
+ v0.13.5 macOS 15+ Universal GPL-3.0 Sem telemetria
@@ -616,7 +616,7 @@ Saiba o que sai do seu Mac.
© 2026 Quem contribui para o Flowlight. O Flowlight é software livre, publicado sob a GNU General Public License v3.0 .
-
Versão 0.13.4 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
+
Versão 0.13.5 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
diff --git a/docs/pt-PT/privacy/index.html b/docs/pt-PT/privacy/index.html
index 3feb199..ca82894 100644
--- a/docs/pt-PT/privacy/index.html
+++ b/docs/pt-PT/privacy/index.html
@@ -173,7 +173,7 @@
© 2026 Quem contribui para o Flowlight. O Flowlight é software livre, publicado sob a GNU General Public License v3.0 .
-
Versão 0.13.4 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
+
Versão 0.13.5 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
diff --git a/docs/pt-PT/threat-model/index.html b/docs/pt-PT/threat-model/index.html
index fe13c7f..1be9a91 100644
--- a/docs/pt-PT/threat-model/index.html
+++ b/docs/pt-PT/threat-model/index.html
@@ -210,7 +210,7 @@ Comunicar uma vulnerabilidade
© 2026 Quem contribui para o Flowlight. O Flowlight é software livre, publicado sob a GNU General Public License v3.0 .
-
Versão 0.13.4 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
+
Versão 0.13.5 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
diff --git a/docs/releases/index.html b/docs/releases/index.html
index 9400873..f1d0a36 100644
--- a/docs/releases/index.html
+++ b/docs/releases/index.html
@@ -55,7 +55,23 @@ What's new
- 0.13.4 October 5, 2026 Latest
+ 0.13.5 October 6, 2026 Latest
+
+ Modify a real response before the app receives it. Create a response-transform rule from
+ Inspect, then use synchronous JavaScript to change eligible JSON or UTF-8 text responses after their origin
+ server answers. The request still reaches the origin; Flowlight clearly records when a rule changed the
+ client-visible response.
+ Safe, scoped scripting. Scripts receive the current method, URL, public request headers and
+ response value only. Credential headers and secret query values are withheld. Streaming, chunked, compressed,
+ oversized and unsupported responses pass through byte-for-byte, and any script error or timeout fails open.
+ Start from the exact endpoint. Both response transforms and static mocks now prefill a useful
+ rule name, host, path and method from the selected request. Response transforms also choose a JSON or text
+ starter function and show redacted captured context without retaining its bodies in settings.
+
+
+
+
+
Mock requests and responses separately. The Mock This Endpoint editor now has Request
and Response tabs: match by host, path and method in one place, then configure the local status, delay,
@@ -1066,7 +1082,7 @@ What's new
© 2026 The Flowlight contributors. Flowlight is free software, released under the
GNU General Public License v3.0 .
-
Version 0.13.4 · Not affiliated with Apple or any AI provider named on this site.
+
Version 0.13.5 · Not affiliated with Apple or any AI provider named on this site.
diff --git a/docs/sitemap.xml b/docs/sitemap.xml
index a9e9f7e..6e2d61f 100644
--- a/docs/sitemap.xml
+++ b/docs/sitemap.xml
@@ -4,7 +4,7 @@
https://flowlight.xinbetween.com/docs/ 2026-10-03
https://flowlight.xinbetween.com/ 2026-10-01
https://flowlight.xinbetween.com/privacy/ 2026-10-03
- https://flowlight.xinbetween.com/releases/ 2026-10-05
+ https://flowlight.xinbetween.com/releases/ 2026-10-06
https://flowlight.xinbetween.com/threat-model/ 2026-09-27
https://flowlight.xinbetween.com/de/about/ 2026-09-26
https://flowlight.xinbetween.com/de/docs/ 2026-09-28
diff --git a/docs/threat-model/index.html b/docs/threat-model/index.html
index 57326f6..07cc946 100644
--- a/docs/threat-model/index.html
+++ b/docs/threat-model/index.html
@@ -209,7 +209,7 @@ Reporting a vulnerability
© 2026 The Flowlight contributors. Flowlight is free software, released under the
GNU General Public License v3.0 .
-
Version 0.13.4 · Not affiliated with Apple or any AI provider named on this site.
+
Version 0.13.5 · Not affiliated with Apple or any AI provider named on this site.
diff --git a/docs/zh-Hans/about/index.html b/docs/zh-Hans/about/index.html
index 274599f..cf58246 100644
--- a/docs/zh-Hans/about/index.html
+++ b/docs/zh-Hans/about/index.html
@@ -137,7 +137,7 @@ 致谢
© 2026 Flowlight 贡献者。Flowlight 是自由软件,依 GNU General Public License v3.0 发布。
-
版本 0.13.4 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
+
版本 0.13.5 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
diff --git a/docs/zh-Hans/docs/index.html b/docs/zh-Hans/docs/index.html
index c6cf628..61ca6f7 100644
--- a/docs/zh-Hans/docs/index.html
+++ b/docs/zh-Hans/docs/index.html
@@ -61,7 +61,7 @@
文档
使用 Flowlight
-
从第一次启动到调整代理规则,需要的都在这里。Flowlight 0.13.4,macOS 15 或更高版本。
+
从第一次启动到调整代理规则,需要的都在这里。Flowlight 0.13.5,macOS 15 或更高版本。
@@ -689,7 +689,7 @@ 限制
© 2026 Flowlight 贡献者。Flowlight 是自由软件,依 GNU General Public License v3.0 发布。
-
版本 0.13.4 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
+
版本 0.13.5 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
diff --git a/docs/zh-Hans/index.html b/docs/zh-Hans/index.html
index 345ca0f..5fb5cb6 100644
--- a/docs/zh-Hans/index.html
+++ b/docs/zh-Hans/index.html
@@ -34,7 +34,7 @@
-
+
跳到正文
@@ -75,7 +75,7 @@ 看清应用的网络活动。
brew install --cask xinbetween/tap/flowlight拷贝
- v0.13.4 macOS 15+ 通用架构 GPL-3.0 无遥测
+ v0.13.5 macOS 15+ 通用架构 GPL-3.0 无遥测
@@ -613,7 +613,7 @@ 知道什么离开了你的 Mac。
© 2026 Flowlight 贡献者。Flowlight 是自由软件,依 GNU General Public License v3.0 发布。
-
版本 0.13.4 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
+
版本 0.13.5 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
diff --git a/docs/zh-Hans/privacy/index.html b/docs/zh-Hans/privacy/index.html
index 4532fc0..b3e850a 100644
--- a/docs/zh-Hans/privacy/index.html
+++ b/docs/zh-Hans/privacy/index.html
@@ -171,7 +171,7 @@
© 2026 Flowlight 贡献者。Flowlight 是自由软件,依 GNU General Public License v3.0 发布。
-
版本 0.13.4 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
+
版本 0.13.5 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
diff --git a/docs/zh-Hans/threat-model/index.html b/docs/zh-Hans/threat-model/index.html
index 8ba6bbb..776c295 100644
--- a/docs/zh-Hans/threat-model/index.html
+++ b/docs/zh-Hans/threat-model/index.html
@@ -197,7 +197,7 @@ 报告漏洞
© 2026 Flowlight 贡献者。Flowlight 是自由软件,依 GNU General Public License v3.0 发布。
-
版本 0.13.4 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
+
版本 0.13.5 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
diff --git a/docs/zh-Hant/about/index.html b/docs/zh-Hant/about/index.html
index 578e1b7..4888615 100644
--- a/docs/zh-Hant/about/index.html
+++ b/docs/zh-Hant/about/index.html
@@ -137,7 +137,7 @@ 致謝
© 2026 Flowlight 貢獻者。Flowlight 是自由軟體,依 GNU General Public License v3.0 發布。
-
版本 0.13.4 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
+
版本 0.13.5 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
diff --git a/docs/zh-Hant/docs/index.html b/docs/zh-Hant/docs/index.html
index 04c6329..4eb84f7 100644
--- a/docs/zh-Hant/docs/index.html
+++ b/docs/zh-Hant/docs/index.html
@@ -61,7 +61,7 @@
說明文件
使用 Flowlight
-
從第一次啟動到調整代理規則,全都在這裡。Flowlight 0.13.4,macOS 15 或以上版本。
+
從第一次啟動到調整代理規則,全都在這裡。Flowlight 0.13.5,macOS 15 或以上版本。
@@ -695,7 +695,7 @@ 限制
© 2026 Flowlight 貢獻者。Flowlight 是自由軟體,依 GNU General Public License v3.0 發布。
-
版本 0.13.4 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
+
版本 0.13.5 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
diff --git a/docs/zh-Hant/index.html b/docs/zh-Hant/index.html
index 1f7fe1b..2c7c08d 100644
--- a/docs/zh-Hant/index.html
+++ b/docs/zh-Hant/index.html
@@ -34,7 +34,7 @@
-
+
跳至內容
@@ -75,7 +75,7 @@ 看見每個 App 的網路活動。brew install --cask xinbetween/tap/flowlight拷貝
- v0.13.4 macOS 15+ 通用架構 GPL-3.0 無遙測
+ v0.13.5 macOS 15+ 通用架構 GPL-3.0 無遙測
@@ -614,7 +614,7 @@ 知道有什麼離開了你的 Mac。
© 2026 Flowlight 貢獻者。Flowlight 是自由軟體,依 GNU General Public License v3.0 發布。
-
版本 0.13.4 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
+
版本 0.13.5 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
diff --git a/docs/zh-Hant/privacy/index.html b/docs/zh-Hant/privacy/index.html
index 2ca3b0f..e75878c 100644
--- a/docs/zh-Hant/privacy/index.html
+++ b/docs/zh-Hant/privacy/index.html
@@ -171,7 +171,7 @@
© 2026 Flowlight 貢獻者。Flowlight 是自由軟體,依 GNU General Public License v3.0 發布。
-
版本 0.13.4 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
+
版本 0.13.5 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
diff --git a/docs/zh-Hant/threat-model/index.html b/docs/zh-Hant/threat-model/index.html
index 9cbd40b..1fdddf8 100644
--- a/docs/zh-Hant/threat-model/index.html
+++ b/docs/zh-Hant/threat-model/index.html
@@ -198,7 +198,7 @@ 回報漏洞
© 2026 Flowlight 貢獻者。Flowlight 是自由軟體,依 GNU General Public License v3.0 發布。
-
版本 0.13.4 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
+
版本 0.13.5 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
diff --git a/project.yml b/project.yml
index 6cd4fe7..f237133 100644
--- a/project.yml
+++ b/project.yml
@@ -11,8 +11,8 @@ settings:
DEVELOPMENT_TEAM: ""
CODE_SIGN_STYLE: Automatic
ENABLE_HARDENED_RUNTIME: YES
- MARKETING_VERSION: "0.13.4"
- CURRENT_PROJECT_VERSION: "25"
+ MARKETING_VERSION: "0.13.5"
+ CURRENT_PROJECT_VERSION: "26"
targets:
Flowlight:
type: application
@@ -37,9 +37,25 @@ targets:
copy:
destination: plugins
subpath: ../Library/SystemExtensions
+ - target: FlowlightResponseScriptHelper
+ embed: true
+ codeSign: true
+ copy:
+ destination: executables
+ subpath: ../Library/Helpers
- sdk: libsqlite3.tbd
- sdk: NetworkExtension.framework
- sdk: SystemExtensions.framework
+ FlowlightResponseScriptHelper:
+ type: tool
+ platform: macOS
+ sources:
+ - FlowlightResponseScriptHelper
+ settings:
+ base:
+ PRODUCT_NAME: FlowlightResponseScriptHelper
+ dependencies:
+ - sdk: JavaScriptCore.framework
FlowlightExtension:
type: system-extension
platform: macOS
diff --git a/site/pages/releases.html b/site/pages/releases.html
index 262386e..8a45e08 100644
--- a/site/pages/releases.html
+++ b/site/pages/releases.html
@@ -13,7 +13,23 @@ What's new
- 0.13.4 October 5, 2026 Latest
+ 0.13.5 October 6, 2026 Latest
+
+ Modify a real response before the app receives it. Create a response-transform rule from
+ Inspect, then use synchronous JavaScript to change eligible JSON or UTF-8 text responses after their origin
+ server answers. The request still reaches the origin; Flowlight clearly records when a rule changed the
+ client-visible response.
+ Safe, scoped scripting. Scripts receive the current method, URL, public request headers and
+ response value only. Credential headers and secret query values are withheld. Streaming, chunked, compressed,
+ oversized and unsupported responses pass through byte-for-byte, and any script error or timeout fails open.
+ Start from the exact endpoint. Both response transforms and static mocks now prefill a useful
+ rule name, host, path and method from the selected request. Response transforms also choose a JSON or text
+ starter function and show redacted captured context without retaining its bodies in settings.
+
+
+
+
+
Mock requests and responses separately. The Mock This Endpoint editor now has Request
and Response tabs: match by host, path and method in one place, then configure the local status, delay,