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..3e1bef6 100644 --- a/docs/404.html +++ b/docs/404.html @@ -80,7 +80,7 @@

That page isn't here

Try the home page,

diff --git a/docs/about/index.html b/docs/about/index.html index 7fc242c..7fda3e6 100644 --- a/docs/about/index.html +++ b/docs/about/index.html @@ -138,7 +138,7 @@

Thanks

diff --git a/docs/de/about/index.html b/docs/de/about/index.html index 1d6d003..00423f9 100644 --- a/docs/de/about/index.html +++ b/docs/de/about/index.html @@ -137,7 +137,7 @@

Dank

diff --git a/docs/de/docs/index.html b/docs/de/docs/index.html index 0e63f64..4957f7c 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.7, macOS 15 oder neuer.

diff --git a/docs/de/index.html b/docs/de/index.html index dc667da..a9f9667 100644 --- a/docs/de/index.html +++ b/docs/de/index.html @@ -34,7 +34,7 @@ - + @@ -75,7 +75,7 @@

Sieh, was deine Apps im Netz tun.
brew install --cask xinbetween/tap/flowlight

-

v0.13.4macOS 15+UniversalGPL-3.0Keine Telemetrie

+

v0.13.7macOS 15+UniversalGPL-3.0Keine Telemetrie

@@ -616,7 +616,7 @@

Wisse, was deinen Mac verlässt.

diff --git a/docs/de/privacy/index.html b/docs/de/privacy/index.html index a84a434..2e39a52 100644 --- a/docs/de/privacy/index.html +++ b/docs/de/privacy/index.html @@ -173,7 +173,7 @@

Kontakt

diff --git a/docs/de/threat-model/index.html b/docs/de/threat-model/index.html index 0b5e52a..44dd57a 100644 --- a/docs/de/threat-model/index.html +++ b/docs/de/threat-model/index.html @@ -213,7 +213,7 @@

Eine Schwachstelle melden

diff --git a/docs/docs/index.html b/docs/docs/index.html index 0afdd26..2e7affc 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.7, macOS 15 or later.

diff --git a/docs/es/about/index.html b/docs/es/about/index.html index c293da1..8195547 100644 --- a/docs/es/about/index.html +++ b/docs/es/about/index.html @@ -137,7 +137,7 @@

Agradecimientos

diff --git a/docs/es/docs/index.html b/docs/es/docs/index.html index 2ef5c6c..2a33794 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.7, macOS 15 o posterior.

diff --git a/docs/es/index.html b/docs/es/index.html index 824d9cf..3259cb9 100644 --- a/docs/es/index.html +++ b/docs/es/index.html @@ -34,7 +34,7 @@ - + @@ -75,7 +75,7 @@

Observa la actividad de red de tus apps.

brew install --cask xinbetween/tap/flowlight

-

v0.13.4macOS 15+UniversalGPL-3.0Sin telemetría

+

v0.13.7macOS 15+UniversalGPL-3.0Sin telemetría

@@ -616,7 +616,7 @@

Ten claro qué sale de tu Mac.

diff --git a/docs/es/privacy/index.html b/docs/es/privacy/index.html index a0a8dcc..cc9d9ea 100644 --- a/docs/es/privacy/index.html +++ b/docs/es/privacy/index.html @@ -173,7 +173,7 @@

Contacto

diff --git a/docs/es/threat-model/index.html b/docs/es/threat-model/index.html index 5006cc0..87ba351 100644 --- a/docs/es/threat-model/index.html +++ b/docs/es/threat-model/index.html @@ -208,7 +208,7 @@

Informar de una vulnerabilidad

diff --git a/docs/fr/about/index.html b/docs/fr/about/index.html index b3dcadd..bfa0e09 100644 --- a/docs/fr/about/index.html +++ b/docs/fr/about/index.html @@ -137,7 +137,7 @@

Remerciements

diff --git a/docs/fr/docs/index.html b/docs/fr/docs/index.html index 418c4a6..d5e0d0d 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.7, macOS 15 ou version ultérieure.

diff --git a/docs/fr/index.html b/docs/fr/index.html index 58f0972..66427d6 100644 --- a/docs/fr/index.html +++ b/docs/fr/index.html @@ -34,7 +34,7 @@ - + @@ -75,7 +75,7 @@

Voyez l’activité réseau de vos apps.

brew install --cask xinbetween/tap/flowlight

-

v0.13.4macOS 15+UniverselGPL-3.0Sans télémétrie

+

v0.13.7macOS 15+UniverselGPL-3.0Sans télémétrie

@@ -616,7 +616,7 @@

Sachez ce qui quitte votre Mac.

diff --git a/docs/fr/privacy/index.html b/docs/fr/privacy/index.html index fe4557f..36d13ed 100644 --- a/docs/fr/privacy/index.html +++ b/docs/fr/privacy/index.html @@ -173,7 +173,7 @@

Contact

diff --git a/docs/fr/threat-model/index.html b/docs/fr/threat-model/index.html index c0c52bd..b447a86 100644 --- a/docs/fr/threat-model/index.html +++ b/docs/fr/threat-model/index.html @@ -211,7 +211,7 @@

Signaler une vulnérabilité

diff --git a/docs/index.html b/docs/index.html index c9f280f..f701fdc 100644 --- a/docs/index.html +++ b/docs/index.html @@ -34,7 +34,7 @@ - + @@ -75,7 +75,7 @@

See your apps' network activity.
brew install --cask xinbetween/tap/flowlight

-

v0.13.4macOS 15+UniversalGPL-3.0No telemetry

+

v0.13.7macOS 15+UniversalGPL-3.0No telemetry

@@ -625,7 +625,7 @@

Know what leaves your Mac.

diff --git a/docs/it/about/index.html b/docs/it/about/index.html index c77d433..6bf1170 100644 --- a/docs/it/about/index.html +++ b/docs/it/about/index.html @@ -137,7 +137,7 @@

Ringraziamenti

diff --git a/docs/it/docs/index.html b/docs/it/docs/index.html index c01342a..7470970 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.7, macOS 15 o successivo.

diff --git a/docs/it/index.html b/docs/it/index.html index 30186c0..892ca77 100644 --- a/docs/it/index.html +++ b/docs/it/index.html @@ -34,7 +34,7 @@ - + @@ -75,7 +75,7 @@

Vedi la rete delle tue app.
Ca Metti una stella su GitHub

brew install --cask xinbetween/tap/flowlight

-

v0.13.4macOS 15+UniversaleGPL-3.0Nessuna telemetria

+

v0.13.7macOS 15+UniversaleGPL-3.0Nessuna telemetria

@@ -616,7 +616,7 @@

Sappi che cosa esce dal tuo Mac.

diff --git a/docs/it/privacy/index.html b/docs/it/privacy/index.html index 4ccdd6f..bf5e4df 100644 --- a/docs/it/privacy/index.html +++ b/docs/it/privacy/index.html @@ -173,7 +173,7 @@

Contatti

diff --git a/docs/it/threat-model/index.html b/docs/it/threat-model/index.html index 6a2fc3b..184c012 100644 --- a/docs/it/threat-model/index.html +++ b/docs/it/threat-model/index.html @@ -209,7 +209,7 @@

Segnalare una vulnerabilità

diff --git a/docs/ja/about/index.html b/docs/ja/about/index.html index 83ed34b..a50f411 100644 --- a/docs/ja/about/index.html +++ b/docs/ja/about/index.html @@ -138,7 +138,7 @@

謝辞

diff --git a/docs/ja/docs/index.html b/docs/ja/docs/index.html index aab8b3c..5069394 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.7、macOS 15 以降。

diff --git a/docs/ja/index.html b/docs/ja/index.html index 319eb8f..4cb93a3 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.4macOS 15+ユニバーサルGPL-3.0テレメトリなし

+

v0.13.7macOS 15+ユニバーサルGPL-3.0テレメトリなし

@@ -615,7 +615,7 @@

Mac から何が出ていくのかを知る。

diff --git a/docs/ja/privacy/index.html b/docs/ja/privacy/index.html index 096aeb2..a2b8612 100644 --- a/docs/ja/privacy/index.html +++ b/docs/ja/privacy/index.html @@ -171,7 +171,7 @@

連絡先

diff --git a/docs/ja/threat-model/index.html b/docs/ja/threat-model/index.html index 18ac79d..dd64e16 100644 --- a/docs/ja/threat-model/index.html +++ b/docs/ja/threat-model/index.html @@ -210,7 +210,7 @@

脆弱性を報告する

diff --git a/docs/ko/about/index.html b/docs/ko/about/index.html index 576ddf4..5544338 100644 --- a/docs/ko/about/index.html +++ b/docs/ko/about/index.html @@ -138,7 +138,7 @@

감사

diff --git a/docs/ko/docs/index.html b/docs/ko/docs/index.html index d1891c8..d3cffe2 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.7, macOS 15 이상.

diff --git a/docs/ko/index.html b/docs/ko/index.html index c4bf333..ec3fa80 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.4macOS 15+유니버설GPL-3.0텔레메트리 없음

+

v0.13.7macOS 15+유니버설GPL-3.0텔레메트리 없음

@@ -616,7 +616,7 @@

내 Mac에서 무엇이 나가는지 아세요.

diff --git a/docs/ko/privacy/index.html b/docs/ko/privacy/index.html index 7caffc9..1e5e69e 100644 --- a/docs/ko/privacy/index.html +++ b/docs/ko/privacy/index.html @@ -174,7 +174,7 @@

문의

diff --git a/docs/ko/threat-model/index.html b/docs/ko/threat-model/index.html index a3b3c45..3b5a356 100644 --- a/docs/ko/threat-model/index.html +++ b/docs/ko/threat-model/index.html @@ -208,7 +208,7 @@

취약점 신고하기

diff --git a/docs/llms-full.txt b/docs/llms-full.txt index a4369f2..6399d57 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.7, 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.7macOS 15+UniversalGPL-3.0No telemetry connectionslive @@ -1413,8 +1413,39 @@ Releases Downloads, checksums and full notes for each version are on GitHub Releases. + 0.13.7 +October 6, 2026Latest + + Current-Xcode signed packaging. Flowlight now packages the manually Developer-ID-signed + app directly from its successful archive. This avoids Xcode 26’s unsupported export path for macOS archives + containing a system extension, while retaining the same notarization and Gatekeeper checks. + + 0.13.6 +October 6, 2026 + + Reliable Developer ID export on current Xcode. Flowlight’s signed release export now uses + the archive’s manual Developer ID signing configuration directly, replacing an obsolete export option that + Xcode 26 rejects. This keeps notarization and Gatekeeper verification available for the response-transform + release. + + 0.13.5 +October 6, 2026 + + 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..c2d9dc8 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.7. - [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..9db96f0 100644 --- a/docs/privacy/index.html +++ b/docs/privacy/index.html @@ -180,7 +180,7 @@

Contact

diff --git a/docs/pt-PT/about/index.html b/docs/pt-PT/about/index.html index eedc325..ab369dd 100644 --- a/docs/pt-PT/about/index.html +++ b/docs/pt-PT/about/index.html @@ -137,7 +137,7 @@

Agradecimentos

diff --git a/docs/pt-PT/docs/index.html b/docs/pt-PT/docs/index.html index 86a3e10..d2a307f 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.7, macOS 15 ou posterior.

diff --git a/docs/pt-PT/index.html b/docs/pt-PT/index.html index 3677a00..4eeb99b 100644 --- a/docs/pt-PT/index.html +++ b/docs/pt-PT/index.html @@ -34,7 +34,7 @@ - + @@ -75,7 +75,7 @@

Veja a atividade de rede das suas apps.

brew install --cask xinbetween/tap/flowlight

-

v0.13.4macOS 15+UniversalGPL-3.0Sem telemetria

+

v0.13.7macOS 15+UniversalGPL-3.0Sem telemetria

@@ -616,7 +616,7 @@

Saiba o que sai do seu Mac.

diff --git a/docs/pt-PT/privacy/index.html b/docs/pt-PT/privacy/index.html index 3feb199..8475c27 100644 --- a/docs/pt-PT/privacy/index.html +++ b/docs/pt-PT/privacy/index.html @@ -173,7 +173,7 @@

Contacto

diff --git a/docs/pt-PT/threat-model/index.html b/docs/pt-PT/threat-model/index.html index fe13c7f..354da6c 100644 --- a/docs/pt-PT/threat-model/index.html +++ b/docs/pt-PT/threat-model/index.html @@ -210,7 +210,7 @@

Comunicar uma vulnerabilidade

diff --git a/docs/releases/index.html b/docs/releases/index.html index 9400873..2ea5f3b 100644 --- a/docs/releases/index.html +++ b/docs/releases/index.html @@ -55,7 +55,42 @@

What's new

-

0.13.4

Latest
+

0.13.7

Latest
+
    +
  • Current-Xcode signed packaging. Flowlight now packages the manually Developer-ID-signed + app directly from its successful archive. This avoids Xcode 26’s unsupported export path for macOS archives + containing a system extension, while retaining the same notarization and Gatekeeper checks.
  • +
+
+ +
+

0.13.6

+
    +
  • Reliable Developer ID export on current Xcode. Flowlight’s signed release export now uses + the archive’s manual Developer ID signing configuration directly, replacing an obsolete export option that + Xcode 26 rejects. This keeps notarization and Gatekeeper verification available for the response-transform + release.
  • +
+
+ +
+

0.13.5

+
    +
  • 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

  • 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 +1101,7 @@

    What's new

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..51e06d8 100644 --- a/docs/threat-model/index.html +++ b/docs/threat-model/index.html @@ -209,7 +209,7 @@

Reporting a vulnerability

diff --git a/docs/zh-Hans/about/index.html b/docs/zh-Hans/about/index.html index 274599f..4d0510f 100644 --- a/docs/zh-Hans/about/index.html +++ b/docs/zh-Hans/about/index.html @@ -137,7 +137,7 @@

致谢

diff --git a/docs/zh-Hans/docs/index.html b/docs/zh-Hans/docs/index.html index c6cf628..b0d85e1 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.7,macOS 15 或更高版本。

diff --git a/docs/zh-Hans/index.html b/docs/zh-Hans/index.html index 345ca0f..6d33063 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.4macOS 15+通用架构GPL-3.0无遥测

+

v0.13.7macOS 15+通用架构GPL-3.0无遥测

@@ -613,7 +613,7 @@

知道什么离开了你的 Mac。

diff --git a/docs/zh-Hans/privacy/index.html b/docs/zh-Hans/privacy/index.html index 4532fc0..1b89b6a 100644 --- a/docs/zh-Hans/privacy/index.html +++ b/docs/zh-Hans/privacy/index.html @@ -171,7 +171,7 @@

联系方式

diff --git a/docs/zh-Hans/threat-model/index.html b/docs/zh-Hans/threat-model/index.html index 8ba6bbb..726cd66 100644 --- a/docs/zh-Hans/threat-model/index.html +++ b/docs/zh-Hans/threat-model/index.html @@ -197,7 +197,7 @@

报告漏洞

diff --git a/docs/zh-Hant/about/index.html b/docs/zh-Hant/about/index.html index 578e1b7..00ae3f8 100644 --- a/docs/zh-Hant/about/index.html +++ b/docs/zh-Hant/about/index.html @@ -137,7 +137,7 @@

致謝

diff --git a/docs/zh-Hant/docs/index.html b/docs/zh-Hant/docs/index.html index 04c6329..5ab21ed 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.7,macOS 15 或以上版本。

diff --git a/docs/zh-Hant/index.html b/docs/zh-Hant/index.html index 1f7fe1b..ea51774 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.4macOS 15+通用架構GPL-3.0無遙測

+

v0.13.7macOS 15+通用架構GPL-3.0無遙測

@@ -614,7 +614,7 @@

知道有什麼離開了你的 Mac。

diff --git a/docs/zh-Hant/privacy/index.html b/docs/zh-Hant/privacy/index.html index 2ca3b0f..fd5e538 100644 --- a/docs/zh-Hant/privacy/index.html +++ b/docs/zh-Hant/privacy/index.html @@ -171,7 +171,7 @@

聯絡

diff --git a/docs/zh-Hant/threat-model/index.html b/docs/zh-Hant/threat-model/index.html index 9cbd40b..2a81809 100644 --- a/docs/zh-Hant/threat-model/index.html +++ b/docs/zh-Hant/threat-model/index.html @@ -198,7 +198,7 @@

回報漏洞

diff --git a/project.yml b/project.yml index 6cd4fe7..bff9edf 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.7" + CURRENT_PROJECT_VERSION: "28" 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/scripts/build-signed.sh b/scripts/build-signed.sh index de751cb..666cfb0 100755 --- a/scripts/build-signed.sh +++ b/scripts/build-signed.sh @@ -7,8 +7,8 @@ # both targets sign manually against profiles downloaded from the developer site. Override the names if yours differ: # APP_PROFILE="Flowlight Developer ID" EXT_PROFILE="Flowlight Extension Developer ID" # -# Developer ID distribution goes through archive + export: with automatic signing, xcodebuild only produces a -# development-signed build, and Xcode creates the Developer ID profiles during the export. +# The archive is the Developer ID artifact. Xcode 26 rejects every export method for this macOS archive containing +# a system extension, so packaging copies the manually signed archive product instead of re-exporting and re-signing it. set -euo pipefail cd "$(dirname "$0")/.." : "${TEAM_ID:?Set TEAM_ID to your Apple Developer team ID}" @@ -18,35 +18,18 @@ APP_PROFILE="${APP_PROFILE:-Flowlight Developer ID}" EXT_PROFILE="${EXT_PROFILE:-Flowlight Extension Developer ID}" ARCHIVE=build/Flowlight.xcarchive EXPORT=build/export -OPTIONS=build/export-options.plist rm -rf "$ARCHIVE" "$EXPORT" mkdir -p build -cat > "$OPTIONS" < - - - - methoddeveloper-id - teamID$TEAM_ID - signingStylemanual - signingCertificateDeveloper ID Application - provisioningProfiles - - com.flowlight.app$APP_PROFILE - com.flowlight.app.filter$EXT_PROFILE - - - -PLIST - xcodebuild -project Flowlight.xcodeproj -scheme Flowlight -configuration Release \ -destination 'generic/platform=macOS' -archivePath "$ARCHIVE" \ DEVELOPMENT_TEAM="$TEAM_ID" CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY="Developer ID Application" \ FL_APP_PROFILE="$APP_PROFILE" FL_EXT_PROFILE="$EXT_PROFILE" OTHER_CODE_SIGN_FLAGS="--timestamp" archive -xcodebuild -exportArchive -archivePath "$ARCHIVE" -exportPath "$EXPORT" \ - -exportOptionsPlist "$OPTIONS" +# The manually signed archive product is already the exact app we distribute. Xcode 26 rejects exportArchive for +# this system-extension archive (including its inferred `development` method), so do not re-export or re-sign it. +mkdir -p "$EXPORT" +cp -R "$ARCHIVE/Products/Applications/Flowlight.app" "$EXPORT/Flowlight.app" # Notarize the app itself: macOS only loads a system extension from a notarized app, and a stapled ticket means it # validates without a network round trip. scripts/notarize.sh is a no-op when no credentials are set. diff --git a/site/pages/releases.html b/site/pages/releases.html index 262386e..c6f985a 100644 --- a/site/pages/releases.html +++ b/site/pages/releases.html @@ -13,7 +13,42 @@

What's new

-

0.13.4

Latest
+

0.13.7

Latest
+
    +
  • Current-Xcode signed packaging. Flowlight now packages the manually Developer-ID-signed + app directly from its successful archive. This avoids Xcode 26’s unsupported export path for macOS archives + containing a system extension, while retaining the same notarization and Gatekeeper checks.
  • +
+
+ +
+

0.13.6

+
    +
  • Reliable Developer ID export on current Xcode. Flowlight’s signed release export now uses + the archive’s manual Developer ID signing configuration directly, replacing an obsolete export option that + Xcode 26 rejects. This keeps notarization and Gatekeeper verification available for the response-transform + release.
  • +
+
+ +
+

0.13.5

+
    +
  • 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

  • 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,