diff --git a/Flowlight/Inspection/InspectionController.swift b/Flowlight/Inspection/InspectionController.swift index 835360ad..e7a52137 100644 --- a/Flowlight/Inspection/InspectionController.swift +++ b/Flowlight/Inspection/InspectionController.swift @@ -18,6 +18,8 @@ final class InspectionController: ObservableObject { static let neverInspect = "inspection.neverInspect" static let systemProxy = "inspection.systemProxy" 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" /// 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 @@ -93,6 +95,7 @@ final class InspectionController: ObservableObject { UserDefaults.standard.stringArray(forKey: Keys.neverInspect) ?? Self.defaultNeverInspect) } let mockRules = { Self.decodeMockRules(UserDefaults.standard.data(forKey: Keys.mockRules)) } + let rewriteRules = { Self.decodeRewriteRules(UserDefaults.standard.data(forKey: Keys.rewriteRules)) } // 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 @@ -116,30 +119,44 @@ final class InspectionController: ObservableObject { proxy.interventions = { [weak self, recorder, proxy] host, clientPort in guard let self else { return nil } let guardrails = self.guardrails() - guard !guardrails.isEmpty else { return nil } + let rewrites = RewriteRules.matching(rewriteRules(), host: host) let owner = recorder.owner(clientPort: clientPort, proxyPort: proxy.port) let agent = owner.agent ?? owner.bundleID - guard GuardrailBook.any(guardrails, agent: agent) else { return nil } + let guardsApply = !guardrails.isEmpty && GuardrailBook.any(guardrails, agent: agent) + // Hold the request only when something would act on it: a guardrail for this agent, or a rewrite rule + // for this host. Everything else streams untouched. + guard guardsApply || !rewrites.isEmpty else { return nil } return { [weak self] head, bytes in - guard let self, let body = Self.body(of: bytes) else { return nil } + guard let self else { return nil } let server = owner.mcpServer - // A call to an MCP server over HTTP, answered here rather than forwarded. - if let refusal = GuardrailEngine.refuse(jsonrpc: body, guardrails: guardrails, agent: agent, server: server) { + // A guardrail that answers an MCP call locally short-circuits — nothing goes upstream to rewrite. + if guardsApply, let body = Self.body(of: bytes), + let refusal = GuardrailEngine.refuse(jsonrpc: body, guardrails: guardrails, agent: agent, server: server) { self.report(refusal.guardrail, subject: refusal.subject, owner: owner, host: host, port: UInt16(clamping: 443), method: head.method, engine: .request) - let answer = MockRule(id: refusal.guardrail.id, name: refusal.guardrail.title, host: host, - path: "*", status: 200, body: refusal.body, blocked: true) - return .answer(answer) + return .answer(MockRule(id: refusal.guardrail.id, name: refusal.guardrail.title, host: host, + path: "*", status: 200, body: refusal.body, blocked: true)) } - // The declaration, which is the lever that means the model is never offered the tool at all. - guard let filtered = GuardrailEngine.filter(request: body, guardrails: guardrails, agent: agent) else { - return nil + var current = bytes + var notes: [String] = [] + // Guardrails first — strip refused tools from the declaration — so a rewrite acts on the filtered body. + if guardsApply, let body = Self.body(of: current), + let filtered = GuardrailEngine.filter(request: body, guardrails: guardrails, agent: agent) { + current = Self.reframe(current, body: filtered.body) + self.report(guardrails.first { g in filtered.removed.contains { g.refuses(agent: agent, server: server, tool: $0) } }, + subject: filtered.removed.joined(separator: ", "), owner: owner, host: host, + port: UInt16(clamping: 443), method: head.method, engine: .request) + notes.append(L("Removed %@", filtered.removed.joined(separator: ", "))) + } + // Then the user's rewrite rules — header and JSON-body edits. + let path = head.target.split(separator: "?").first.map(String.init) ?? "/" + if !rewrites.isEmpty, + let edited = RewriteRules.apply(rewrites, to: current, host: host, method: head.method, path: path) { + current = edited.data + notes.append(edited.note) } - self.report(guardrails.first { g in filtered.removed.contains { g.refuses(agent: agent, server: server, tool: $0) } }, - subject: filtered.removed.joined(separator: ", "), owner: owner, host: host, - port: UInt16(clamping: 443), method: head.method, engine: .request) - return .replace(Self.reframe(bytes, body: filtered.body), - note: L("Removed %@", filtered.removed.joined(separator: ", "))) + guard !notes.isEmpty else { return nil } + return .replace(current, note: notes.joined(separator: " · ")) } } proxy.onAnswered = { [weak self, recorder, proxy] rule, flow, head in @@ -160,6 +177,8 @@ 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. + guard RewriteRules.matching(rewriteRules(), 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) @@ -267,6 +286,24 @@ final class InspectionController: ObservableObject { return (try? JSONDecoder().decode([MockRule].self, from: data)) ?? [] } + /// Rules that rewrite outgoing requests. Stored like mocks: settings, not history, so "remove everything + /// Flowlight recorded" leaves them alone. + var rewriteRules: [RewriteRule] { + get { Self.decodeRewriteRules(UserDefaults.standard.data(forKey: Keys.rewriteRules)) } + set { + UserDefaults.standard.set(try? JSONEncoder().encode(newValue), forKey: Keys.rewriteRules) + objectWillChange.send() + } + } + + /// How many rewrite rules are live, so a request quietly being changed isn't mistaken for the server's own reply. + var activeRewriteRules: Int { rewriteRules.filter(\.enabled).count } + + nonisolated static func decodeRewriteRules(_ data: Data?) -> [RewriteRule] { + guard let data else { return [] } + return (try? JSONDecoder().decode([RewriteRule].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/RewriteRule.swift b/Flowlight/Inspection/RewriteRule.swift new file mode 100644 index 00000000..e0ee6055 --- /dev/null +++ b/Flowlight/Inspection/RewriteRule.swift @@ -0,0 +1,192 @@ +import Foundation + +/// An edit to one outgoing header: replace it (`set`), append another copy (`add`), or drop it (`remove`). +struct HeaderEdit: Codable, Equatable, Identifiable, Sendable { + enum Op: String, Codable, Sendable, CaseIterable { case set, add, remove } + var id = UUID() + var op: Op = .set + var name = "" + var value = "" + + init(id: UUID = UUID(), op: Op = .set, name: String = "", value: String = "") { + self.id = id; self.op = op; self.name = name; self.value = value + } + + enum CodingKeys: String, CodingKey { case id, op, name, value } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + op = try c.decodeIfPresent(Op.self, forKey: .op) ?? .set + name = try c.decodeIfPresent(String.self, forKey: .name) ?? "" + value = try c.decodeIfPresent(String.self, forKey: .value) ?? "" + } +} + +/// An edit to the request's JSON body, addressed by a dotted key path into objects (`metadata.user`). `set` creates +/// the path if needed; `remove` deletes the leaf. The value is parsed as JSON when it can be (`0.7`, `true`, +/// `{"a":1}`) and taken as a plain string otherwise. +struct BodyEdit: Codable, Equatable, Identifiable, Sendable { + enum Op: String, Codable, Sendable, CaseIterable { case set, remove } + var id = UUID() + var op: Op = .set + var path = "" + var value = "" + + init(id: UUID = UUID(), op: Op = .set, path: String = "", value: String = "") { + self.id = id; self.op = op; self.path = path; self.value = value + } + + enum CodingKeys: String, CodingKey { case id, op, path, value } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + op = try c.decodeIfPresent(Op.self, forKey: .op) ?? .set + path = try c.decodeIfPresent(String.self, forKey: .path) ?? "" + value = try c.decodeIfPresent(String.self, forKey: .value) ?? "" + } +} + +/// A rule that rewrites a matching outgoing request before it is forwarded upstream — changing headers or the JSON +/// body. Like a mock, but it edits the request and lets it through rather than answering it: add an `Authorization` +/// header, pin `model`, strip a tracking field. Matched like a mock (host / path glob / method), and applied by the +/// same intervention path the guardrails use. Only requests Flowlight decrypts and can buffer (bodies up to a few MB, +/// not chunked or streamed) can be rewritten. +struct RewriteRule: 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. + var path = "*" + /// Empty (or `ANY`) matches any method. + var method = "" + var headers: [HeaderEdit] = [] + var body: [BodyEdit] = [] + + static let methods = MockRule.methods + + var title: String { + name.isEmpty ? "\(method.isEmpty ? "ANY" : method.uppercased()) \(host)\(path)" : name + } + + init(id: UUID = UUID(), enabled: Bool = true, name: String = "", host: String = "", path: String = "*", + method: String = "", headers: [HeaderEdit] = [], body: [BodyEdit] = []) { + self.id = id; self.enabled = enabled; self.name = name; self.host = host; self.path = path + self.method = method; self.headers = headers; self.body = body + } + + enum CodingKeys: String, CodingKey { case id, enabled, name, host, path, method, headers, body } + 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) ?? "" + headers = try c.decodeIfPresent([HeaderEdit].self, forKey: .headers) ?? [] + body = try c.decodeIfPresent([BodyEdit].self, forKey: .body) ?? [] + } +} + +// MARK: Matching & applying + +/// Pure functions over plain data — no sockets — so the whole rewrite is testable without a proxy. +enum RewriteRules { + /// The enabled rules that could touch this host. Asked once per connection, so a host no rule names never pays. + static func matching(_ rules: [RewriteRule], host: String) -> [RewriteRule] { + rules.filter { $0.enabled && GlobMatch.host($0.host, host) } + } + + /// Apply every rule that matches this request to the framed bytes (full head + body). Returns the rewritten + /// request and a short note of what changed, or nil when nothing matched or nothing changed. + static func apply(_ rules: [RewriteRule], to request: Data, host: String, method: String, path: String) + -> (data: Data, note: String)? { + let applicable = rules.filter { + $0.enabled && GlobMatch.host($0.host, host) && GlobMatch.method($0.method, method) && GlobMatch.path($0.path, path) + } + guard !applicable.isEmpty else { return nil } + guard let sep = request.range(of: Data("\r\n\r\n".utf8)) else { return nil } + + let headText = String(decoding: request[request.startIndex.. Any { + if let data = "[\(s)]".data(using: .utf8), + let array = try? JSONSerialization.jsonObject(with: data) as? [Any], let first = array.first { + return first + } + return s + } + + private static func setJSON(_ object: inout [String: Any], path: [String], value: Any) { + guard let key = path.first else { return } + if path.count == 1 { object[key] = value; return } + var child = object[key] as? [String: Any] ?? [:] + setJSON(&child, path: Array(path.dropFirst()), value: value) + object[key] = child + } + + private static func removeJSON(_ object: inout [String: Any], path: [String]) { + guard let key = path.first else { return } + if path.count == 1 { object.removeValue(forKey: key); return } + guard var child = object[key] as? [String: Any] else { return } + removeJSON(&child, path: Array(path.dropFirst())) + object[key] = child + } +} diff --git a/Flowlight/UI/InspectView.swift b/Flowlight/UI/InspectView.swift index 53ada31c..73dbe8cd 100644 --- a/Flowlight/UI/InspectView.swift +++ b/Flowlight/UI/InspectView.swift @@ -277,6 +277,7 @@ private struct InspectionSetup: View { @State private var confirmRemove = false @State private var showAdvanced = false @State private var showMocks = false + @State private var showRewrites = false @State private var confirmTurnOn = false @Environment(\.openURL) private var openURL @@ -402,6 +403,19 @@ private struct InspectionSetup: View { } } } + + DisclosureGroup(isExpanded: $showRewrites) { + RewriteRulesSection(inspection: inspection).padding(.top, 10) + } label: { + HStack(spacing: 8) { + Text(L("Modify requests")).font(.headline) + if inspection.activeRewriteRules > 0 { + Label(inspection.activeRewriteRules == 1 ? L("1 on") : L("%lld on", inspection.activeRewriteRules), + systemImage: "slider.horizontal.3") + .font(.caption.bold()).foregroundStyle(FL.tool) + } + } + } } .measured(Measure.prose) .confirmationDialog(L("macOS will ask you twice"), isPresented: $confirmTurnOn) { diff --git a/Flowlight/UI/RewriteRulesView.swift b/Flowlight/UI/RewriteRulesView.swift new file mode 100644 index 00000000..f3a7f02f --- /dev/null +++ b/Flowlight/UI/RewriteRulesView.swift @@ -0,0 +1,202 @@ +import SwiftUI + +/// Modify requests: rules that edit an outgoing request's headers or JSON body before it's forwarded. Sits with the +/// rest of inspection setup — a rule can only change a request Flowlight decrypts. +struct RewriteRulesSection: View { + @ObservedObject var inspection: InspectionController + @State private var editing: RewriteRule? + @State private var isNew = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(L("Change a request on its way out — add or replace a header, pin a field in the JSON body, or strip one — and let it continue to the server. Like a mock, but it edits the request instead of answering it.")) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + Label(L("Only requests Flowlight decrypts can be rewritten, and only buffered ones — bodies up to a few megabytes. Tunnelled, pinned, chunked or streamed uploads pass through untouched."), + systemImage: "info.circle") + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + if !inspection.enabled { + Label(L("HTTPS inspection is off, so nothing is decrypted and no rule can change anything yet."), + systemImage: "exclamationmark.triangle") + .font(.caption).foregroundStyle(FL.warning).fixedSize(horizontal: false, vertical: true) + } + + if inspection.rewriteRules.isEmpty { + Text(L("No request rules.")).font(.caption).foregroundStyle(.tertiary) + } else { + VStack(spacing: 0) { + ForEach(Array(inspection.rewriteRules.enumerated()), id: \.element.id) { index, rule in + row(rule, index: index) + if index < inspection.rewriteRules.count - 1 { Divider() } + } + } + .padding(.vertical, 2) + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 6)) + Text(L("Every matching rule is applied, in order.")) + .font(.caption).foregroundStyle(.secondary) + } + + HStack { + Button(L("Add Rule…")) { + editing = RewriteRule(host: "", path: "/*") + isNew = true + } + Spacer() + if inspection.activeRewriteRules > 0 { + Button(L("Turn All Off")) { + inspection.rewriteRules = inspection.rewriteRules.map { var r = $0; r.enabled = false; return r } + } + } + } + } + .sheet(item: $editing) { rule in + RewriteRuleEditor(rule: rule, isNew: isNew) { saved in + if let at = inspection.rewriteRules.firstIndex(where: { $0.id == saved.id }) { + inspection.rewriteRules[at] = saved + } else { + inspection.rewriteRules.append(saved) + } + } + } + } + + private func row(_ rule: RewriteRule, index: Int) -> some View { + HStack(spacing: 8) { + Toggle("", isOn: Binding(get: { rule.enabled }, set: { on in + var copy = rule; copy.enabled = on + inspection.rewriteRules[index] = copy + })) + .toggleStyle(.switch).controlSize(.mini).labelsHidden() + .accessibilityLabel(L("Enable %@", rule.title)) + VStack(alignment: .leading, spacing: 1) { + Text(rule.title).font(.callout).lineLimit(1) + Text(summary(rule)).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.rewriteRules.removeAll { $0.id == rule.id } + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless).accessibilityLabel(L("Remove %@", rule.title)) + } + .padding(.horizontal, 8).padding(.vertical, 5) + } + + private func summary(_ rule: RewriteRule) -> String { + let scope = "\(rule.method.isEmpty ? "ANY" : rule.method.uppercased()) \(rule.host)\(rule.path)" + let edits = rule.headers.count + rule.body.count + return "\(scope) · \(L("%lld edit(s)", edits))" + } +} + +/// One rewrite rule, edited in a sheet: where it matches, and the header and body edits it makes. +struct RewriteRuleEditor: View { + @State var rule: RewriteRule + var isNew: Bool + var save: (RewriteRule) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(isNew ? L("New request rule") : L("Edit request rule")).font(.title3.bold()) + + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 10, verticalSpacing: 8) { + GridRow { + Text(L("Name")).gridColumnAlignment(.trailing).foregroundStyle(.secondary) + TextField(L("Optional, e.g. “Force temperature”"), text: $rule.name) + } + GridRow { + Text(L("Host")).gridColumnAlignment(.trailing).foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + TextField(L("api.example.com"), text: $rule.host) + Text(L("Exactly that host. Write *.example.com to cover the domain and its subdomains.")) + .font(.caption).foregroundStyle(.secondary) + } + } + 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(RewriteRule.methods, id: \.self) { Text($0).tag($0) } + } + .labelsHidden().frame(width: 130) + } + } + + Divider() + + // Header edits + VStack(alignment: .leading, spacing: 6) { + Text(L("Headers")).font(.caption.bold()).foregroundStyle(.secondary) + ForEach($rule.headers) { $edit in + HStack(spacing: 6) { + Picker("", selection: $edit.op) { + Text(L("Set")).tag(HeaderEdit.Op.set) + Text(L("Add")).tag(HeaderEdit.Op.add) + Text(L("Remove")).tag(HeaderEdit.Op.remove) + }.labelsHidden().frame(width: 92) + TextField(L("Header name"), text: $edit.name).frame(width: 150) + TextField(L("Value"), text: $edit.value).disabled(edit.op == .remove) + .opacity(edit.op == .remove ? 0.4 : 1) + Button(role: .destructive) { rule.headers.removeAll { $0.id == edit.id } } label: { + Image(systemName: "minus.circle") + }.buttonStyle(.borderless) + } + } + Button(L("Add header edit")) { rule.headers.append(HeaderEdit()) }.controlSize(.small) + } + + // Body edits + VStack(alignment: .leading, spacing: 6) { + Text(L("JSON body")).font(.caption.bold()).foregroundStyle(.secondary) + ForEach($rule.body) { $edit in + HStack(spacing: 6) { + Picker("", selection: $edit.op) { + Text(L("Set")).tag(BodyEdit.Op.set) + Text(L("Remove")).tag(BodyEdit.Op.remove) + }.labelsHidden().frame(width: 92) + TextField(L("key.path"), text: $edit.path).font(.caption.monospaced()).frame(width: 150) + TextField(L("value (JSON or text)"), text: $edit.value).font(.caption.monospaced()) + .disabled(edit.op == .remove).opacity(edit.op == .remove ? 0.4 : 1) + Button(role: .destructive) { rule.body.removeAll { $0.id == edit.id } } label: { + Image(systemName: "minus.circle") + }.buttonStyle(.borderless) + } + } + Button(L("Add body edit")) { rule.body.append(BodyEdit()) }.controlSize(.small) + Text(L("Dotted path into the JSON object (metadata.user). A value that is valid JSON (0.7, true, {\"a\":1}) is used as-is; anything else is a string. Only requests with a JSON body are changed.")) + .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(rule.host.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding(20) + .frame(width: 600) + } + + private func cleaned() -> RewriteRule { + 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() + // Drop blank edits a half-filled row would leave behind. + copy.headers = copy.headers.filter { !$0.name.trimmingCharacters(in: .whitespaces).isEmpty } + copy.body = copy.body.filter { !$0.path.trimmingCharacters(in: .whitespaces).isEmpty } + return copy + } +} diff --git a/FlowlightTests/RewriteRuleTests.swift b/FlowlightTests/RewriteRuleTests.swift new file mode 100644 index 00000000..bf28c02e --- /dev/null +++ b/FlowlightTests/RewriteRuleTests.swift @@ -0,0 +1,135 @@ +import XCTest +@testable import Flowlight + +/// Rewriting an outgoing request is pure data work — these exercise it without a proxy. The framing has to stay +/// correct (Content-Length must agree with the body) or the connection would hang, so several checks assert on it. +final class RewriteRuleTests: XCTestCase { + private func request(_ method: String, _ target: String, headers: [String] = [], body: String = "") -> Data { + var s = "\(method) \(target) HTTP/1.1\r\n" + for h in headers { s += h + "\r\n" } + if !body.isEmpty { s += "Content-Length: \(body.utf8.count)\r\n" } + s += "\r\n" + body + return Data(s.utf8) + } + + private func parts(_ data: Data) -> (head: [String], body: String) { + let sep = data.range(of: Data("\r\n\r\n".utf8))! + let head = String(decoding: data[.. [String: Any] { + let sep = data.range(of: Data("\r\n\r\n".utf8))! + return (try? JSONSerialization.jsonObject(with: Data(data[sep.upperBound...]))) as? [String: Any] ?? [:] + } + + private func apply(_ rule: RewriteRule, _ data: Data, host: String = "api.example.com", method: String = "POST", path: String = "/v1/messages") -> Data? { + RewriteRules.apply([rule], to: data, host: host, method: method, path: path)?.data + } + + // MARK: Headers + + /// `set` replaces an existing header rather than duplicating it. + func testHeaderSetReplaces() throws { + let rule = RewriteRule(host: "api.example.com", headers: [HeaderEdit(op: .set, name: "Authorization", value: "Bearer new")]) + let out = try XCTUnwrap(apply(rule, request("POST", "/v1/messages", headers: ["Authorization: Bearer old"]))) + let auth = parts(out).head.filter { $0.lowercased().hasPrefix("authorization:") } + XCTAssertEqual(auth, ["Authorization: Bearer new"]) + } + + /// `add` appends and keeps what was there. + func testHeaderAddAppends() throws { + let rule = RewriteRule(host: "api.example.com", headers: [HeaderEdit(op: .add, name: "X-Trace", value: "1")]) + let out = try XCTUnwrap(apply(rule, request("POST", "/v1/messages", headers: ["X-Trace: 0"]))) + XCTAssertEqual(parts(out).head.filter { $0.hasPrefix("X-Trace:") }, ["X-Trace: 0", "X-Trace: 1"]) + } + + /// `remove` drops the header. + func testHeaderRemove() throws { + let rule = RewriteRule(host: "api.example.com", headers: [HeaderEdit(op: .remove, name: "Cookie")]) + let out = try XCTUnwrap(apply(rule, request("POST", "/v1/messages", headers: ["Cookie: a=b"]))) + XCTAssertFalse(parts(out).head.contains { $0.lowercased().hasPrefix("cookie:") }) + } + + /// A newline in a header value can't forge a second header or request. + func testHeaderValueIsSanitised() throws { + let rule = RewriteRule(host: "api.example.com", headers: [HeaderEdit(op: .set, name: "X-Evil", value: "ok\r\nInjected: yes")]) + let out = try XCTUnwrap(apply(rule, request("POST", "/v1/messages"))) + XCTAssertFalse(parts(out).head.contains { $0.lowercased().hasPrefix("injected:") }) + } + + // MARK: Body + + /// Setting a scalar parses it as JSON (a number stays a number), and Content-Length is recomputed. + func testBodySetNumberAndReframes() throws { + let rule = RewriteRule(host: "api.example.com", body: [BodyEdit(op: .set, path: "temperature", value: "0.2")]) + let out = try XCTUnwrap(apply(rule, request("POST", "/v1/messages", body: #"{"model":"x","temperature":0.9}"#))) + XCTAssertEqual(bodyJSON(out)["temperature"] as? Double, 0.2) + let cl = parts(out).head.first { $0.lowercased().hasPrefix("content-length:") }! + let declared = Int(cl.split(separator: ":")[1].trimmingCharacters(in: .whitespaces))! + let actual = String(decoding: out[out.range(of: Data("\r\n\r\n".utf8))!.upperBound...], as: UTF8.self).utf8.count + XCTAssertEqual(declared, actual, "Content-Length must agree with the rewritten body") + } + + /// An unquoted value that isn't valid JSON is taken as a plain string. + func testBodySetStringFallback() throws { + let rule = RewriteRule(host: "api.example.com", body: [BodyEdit(op: .set, path: "model", value: "claude-opus")]) + let out = try XCTUnwrap(apply(rule, request("POST", "/v1/messages", body: #"{"model":"x"}"#))) + XCTAssertEqual(bodyJSON(out)["model"] as? String, "claude-opus") + } + + /// A nested path is created if it isn't there. + func testBodySetNestedCreatesPath() throws { + let rule = RewriteRule(host: "api.example.com", body: [BodyEdit(op: .set, path: "metadata.user", value: #""alice""#)]) + let out = try XCTUnwrap(apply(rule, request("POST", "/v1/messages", body: #"{"model":"x"}"#))) + XCTAssertEqual((bodyJSON(out)["metadata"] as? [String: Any])?["user"] as? String, "alice") + } + + /// `remove` deletes the leaf. + func testBodyRemove() throws { + let rule = RewriteRule(host: "api.example.com", body: [BodyEdit(op: .remove, path: "stream")]) + let out = try XCTUnwrap(apply(rule, request("POST", "/v1/messages", body: #"{"model":"x","stream":true}"#))) + XCTAssertNil(bodyJSON(out)["stream"]) + XCTAssertEqual(bodyJSON(out)["model"] as? String, "x") + } + + /// A non-JSON body is left alone while header edits still apply. + func testNonJSONBodyKeepsBodyButEditsHeaders() throws { + let rule = RewriteRule(host: "api.example.com", + headers: [HeaderEdit(op: .set, name: "X-Tag", value: "1")], + body: [BodyEdit(op: .set, path: "model", value: "y")]) + let out = try XCTUnwrap(apply(rule, request("POST", "/form", headers: [], body: "a=1&b=2"), path: "/form")) + XCTAssertEqual(parts(out).body, "a=1&b=2") + XCTAssertTrue(parts(out).head.contains("X-Tag: 1")) + } + + // MARK: Matching + + /// A rule for another host, method or path doesn't touch the request. + func testNonMatchIsNil() { + let rule = RewriteRule(host: "other.example.com", headers: [HeaderEdit(op: .set, name: "X", value: "1")]) + XCTAssertNil(apply(rule, request("POST", "/v1/messages"))) + let m = RewriteRule(host: "api.example.com", method: "GET", headers: [HeaderEdit(op: .set, name: "X", value: "1")]) + XCTAssertNil(apply(m, request("POST", "/v1/messages"))) + let p = RewriteRule(host: "api.example.com", path: "/other/*", headers: [HeaderEdit(op: .set, name: "X", value: "1")]) + XCTAssertNil(apply(p, request("POST", "/v1/messages"))) + } + + /// A disabled rule, or one with no effective edits, changes nothing. + func testNoChangeIsNil() { + let disabled = RewriteRule(enabled: false, host: "api.example.com", headers: [HeaderEdit(op: .set, name: "X", value: "1")]) + XCTAssertNil(apply(disabled, request("POST", "/v1/messages"))) + let empty = RewriteRule(host: "api.example.com") + XCTAssertNil(apply(empty, request("POST", "/v1/messages"))) + } + + /// Old stored rules with fields missing still decode. + func testDecodesWithMissingFields() throws { + let json = Data(#"{"host":"api.example.com"}"#.utf8) + let rule = try JSONDecoder().decode(RewriteRule.self, from: json) + XCTAssertEqual(rule.host, "api.example.com") + XCTAssertTrue(rule.enabled) + XCTAssertEqual(rule.path, "*") + XCTAssertTrue(rule.headers.isEmpty) + } +} diff --git a/docs/404.html b/docs/404.html index a4cf886c..f871b1fc 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 f3d86c1c..7bd16466 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 232501c2..f231b8e5 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 bdda1dd6..435ac392 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.12.0, macOS 15 oder neuer.

+

Alles vom ersten Start bis zum Feinschliff an den Agentenregeln. Flowlight 0.13.0, macOS 15 oder neuer.

diff --git a/docs/de/index.html b/docs/de/index.html index 20a54061..9aca252a 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.12.0macOS 15+UniversalGPL-3.0Keine Telemetrie

+

v0.13.0macOS 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 c0a785fd..822d7b21 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 760802e0..b01b8ab0 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 d672fe35..c71c6e0f 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.12.0, macOS 15 or later.

+

Everything from the first launch to tuning the agent rules. Flowlight 0.13.0, macOS 15 or later.

diff --git a/docs/es/about/index.html b/docs/es/about/index.html index 2b82374d..d689032f 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 eb9f599d..0bf4de8e 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.12.0, macOS 15 o posterior.

+

Todo, desde el primer arranque hasta el ajuste de las reglas de agentes. Flowlight 0.13.0, macOS 15 o posterior.

diff --git a/docs/es/index.html b/docs/es/index.html index b2c75210..ac26d224 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.12.0macOS 15+UniversalGPL-3.0Sin telemetría

+

v0.13.0macOS 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 6b043677..8ebc5f07 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 5be27b97..69681702 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 e4819a73..32bdea25 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 87e15356..fb2bb297 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.12.0, macOS 15 ou version ultérieure.

+

Tout, du premier lancement au réglage des règles des agents. Flowlight 0.13.0, macOS 15 ou version ultérieure.

diff --git a/docs/fr/index.html b/docs/fr/index.html index 641c61e7..339245ef 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.12.0macOS 15+UniverselGPL-3.0Sans télémétrie

+

v0.13.0macOS 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 5a7f9b82..611c048e 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 e48f9d3e..7276be5a 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 d151de1d..53dd98b6 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.12.0macOS 15+UniversalGPL-3.0No telemetry

+

v0.13.0macOS 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 a2aac2d7..a4d95f2a 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 a8d3b8e2..bc81260d 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.12.0, macOS 15 o successivo.

+

Tutto, dal primo avvio alla messa a punto delle regole per gli agenti. Flowlight 0.13.0, macOS 15 o successivo.

diff --git a/docs/it/index.html b/docs/it/index.html index 0cf14e7d..81876b2a 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.12.0macOS 15+UniversaleGPL-3.0Nessuna telemetria

+

v0.13.0macOS 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 1bcb9bff..e87a59d3 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 93a01c5c..5a581a6b 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 3c1bef77..bc4b631b 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 28d432cc..7f2e472f 100644 --- a/docs/ja/docs/index.html +++ b/docs/ja/docs/index.html @@ -61,7 +61,7 @@

ドキュメント

Flowlight の使い方

-

初回起動からエージェントのルールの調整まで、すべてここに。Flowlight 0.12.0、macOS 15 以降。

+

初回起動からエージェントのルールの調整まで、すべてここに。Flowlight 0.13.0、macOS 15 以降。

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

+

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

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

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

diff --git a/docs/ja/privacy/index.html b/docs/ja/privacy/index.html index 00be2b52..a5e9af2e 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 ac5e780f..23a38aba 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 aa21453a..0ec3545f 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 48e97dac..c0f4ccb0 100644 --- a/docs/ko/docs/index.html +++ b/docs/ko/docs/index.html @@ -61,7 +61,7 @@

문서

Flowlight 사용하기

-

첫 실행부터 에이전트 규칙 조정까지 전부. Flowlight 0.12.0, macOS 15 이상.

+

첫 실행부터 에이전트 규칙 조정까지 전부. Flowlight 0.13.0, macOS 15 이상.

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

+

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

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

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

diff --git a/docs/ko/privacy/index.html b/docs/ko/privacy/index.html index 2992c1ca..3127e950 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 e319b567..4eb0ce1d 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 7f3a4f04..c5a5d294 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.12.0, macOS 15 or later. + Everything from the first launch to tuning the agent rules. Flowlight 0.13.0, macOS 15 or later. On this page @@ -839,7 +839,7 @@ Understand your AI agents. brew install --cask xinbetween/tap/flowlightCopy - v0.12.0macOS 15+UniversalGPL-3.0No telemetry + v0.13.0macOS 15+UniversalGPL-3.0No telemetry connectionslive @@ -1398,8 +1398,20 @@ Releases Downloads, checksums and full notes for each version are on GitHub Releases. + 0.13.0 +October 1, 2026Latest + + Change a request on its way out. A new kind of rule edits a matching outgoing + request before it reaches the server — add or replace a header, pin a field in the JSON body, or strip + one — then lets it continue. It's the mock feature's sibling: where a mock answers a request, this one + rewrites it and forwards it. Matched the same way, by host, path and method. + + Where it lives. "Modify requests" sits beside "Mock responses" in the inspection + setup. Like a mock, it only touches requests Flowlight decrypts, and the change is recorded on the + request so it's never mistaken for what the server actually received. + 0.12.0 -September 30, 2026Latest +September 30, 2026 A refreshed interface, on one design system. Flowlight now draws from a single set of tokens — a brand palette that resolves for light and dark, consistent spacing, and shared surfaces — so diff --git a/docs/llms.txt b/docs/llms.txt index d816b8ab..106f4362 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.12.0. +> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.13.0. - [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 d466c591..d057e20a 100644 --- a/docs/privacy/index.html +++ b/docs/privacy/index.html @@ -174,7 +174,7 @@

Contact

diff --git a/docs/pt-PT/about/index.html b/docs/pt-PT/about/index.html index 811e6696..53ae95f3 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 c7ea1a75..9ab38622 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.12.0, macOS 15 ou posterior.

+

Tudo, da primeira abertura ao ajuste das regras dos agentes. Flowlight 0.13.0, macOS 15 ou posterior.

diff --git a/docs/pt-PT/index.html b/docs/pt-PT/index.html index 3e7ffb80..beb2c2b6 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.12.0macOS 15+UniversalGPL-3.0Sem telemetria

+

v0.13.0macOS 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 2b288b59..8fd8b112 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 381a09df..87665077 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 bac6941a..7ccfb5b0 100644 --- a/docs/releases/index.html +++ b/docs/releases/index.html @@ -55,7 +55,20 @@

What's new

-

0.12.0

Latest
+

0.13.0

Latest
+
    +
  • Change a request on its way out. A new kind of rule edits a matching outgoing + request before it reaches the server — add or replace a header, pin a field in the JSON body, or strip + one — then lets it continue. It's the mock feature's sibling: where a mock answers a request, this one + rewrites it and forwards it. Matched the same way, by host, path and method.
  • +
  • Where it lives. "Modify requests" sits beside "Mock responses" in the inspection + setup. Like a mock, it only touches requests Flowlight decrypts, and the change is recorded on the + request so it's never mistaken for what the server actually received.
  • +
+
+ +
+

0.12.0

  • A refreshed interface, on one design system. Flowlight now draws from a single set of tokens — a brand palette that resolves for light and dark, consistent spacing, and shared surfaces — so @@ -1013,7 +1026,7 @@

    What's new

diff --git a/docs/sitemap.xml b/docs/sitemap.xml index 23b47e0a..dae7536a 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -4,7 +4,7 @@ https://flowlight.xinbetween.com/docs/2026-09-28 https://flowlight.xinbetween.com/2026-09-30 https://flowlight.xinbetween.com/privacy/2026-09-27 - https://flowlight.xinbetween.com/releases/2026-09-30 + https://flowlight.xinbetween.com/releases/2026-10-01 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 e45d453d..937520ba 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 0d7d860a..0d1f1919 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 8ed00209..ecfa416d 100644 --- a/docs/zh-Hans/docs/index.html +++ b/docs/zh-Hans/docs/index.html @@ -61,7 +61,7 @@

文档

使用 Flowlight

-

从第一次启动到调整代理规则,需要的都在这里。Flowlight 0.12.0,macOS 15 或更高版本。

+

从第一次启动到调整代理规则,需要的都在这里。Flowlight 0.13.0,macOS 15 或更高版本。

diff --git a/docs/zh-Hans/index.html b/docs/zh-Hans/index.html index f2f6de4a..5cbeb776 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.12.0macOS 15+通用架构GPL-3.0无遥测

+

v0.13.0macOS 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 68436f19..8b3ae140 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 aab50f5c..7567a16e 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 357a6943..ae950462 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 1c425c59..ff1f7dc8 100644 --- a/docs/zh-Hant/docs/index.html +++ b/docs/zh-Hant/docs/index.html @@ -61,7 +61,7 @@

說明文件

使用 Flowlight

-

從第一次啟動到調整代理規則,全都在這裡。Flowlight 0.12.0,macOS 15 或以上版本。

+

從第一次啟動到調整代理規則,全都在這裡。Flowlight 0.13.0,macOS 15 或以上版本。

diff --git a/docs/zh-Hant/index.html b/docs/zh-Hant/index.html index b5d07a54..47574b34 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.12.0macOS 15+通用架構GPL-3.0無遙測

+

v0.13.0macOS 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 a07830ed..ed46a274 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 51ccfaa1..7b1cfac5 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 68c3fb41..a57e66a7 100644 --- a/project.yml +++ b/project.yml @@ -11,7 +11,7 @@ settings: DEVELOPMENT_TEAM: "" CODE_SIGN_STYLE: Automatic ENABLE_HARDENED_RUNTIME: YES - MARKETING_VERSION: "0.12.0" + MARKETING_VERSION: "0.13.0" CURRENT_PROJECT_VERSION: "22" targets: Flowlight: diff --git a/site/pages/releases.html b/site/pages/releases.html index 13005dad..272b14c0 100644 --- a/site/pages/releases.html +++ b/site/pages/releases.html @@ -13,7 +13,20 @@

What's new

-

0.12.0

Latest
+

0.13.0

Latest
+
    +
  • Change a request on its way out. A new kind of rule edits a matching outgoing + request before it reaches the server — add or replace a header, pin a field in the JSON body, or strip + one — then lets it continue. It's the mock feature's sibling: where a mock answers a request, this one + rewrites it and forwards it. Matched the same way, by host, path and method.
  • +
  • Where it lives. "Modify requests" sits beside "Mock responses" in the inspection + setup. Like a mock, it only touches requests Flowlight decrypts, and the change is recorded on the + request so it's never mistaken for what the server actually received.
  • +
+
+ +
+

0.12.0

  • A refreshed interface, on one design system. Flowlight now draws from a single set of tokens — a brand palette that resolves for light and dark, consistent spacing, and shared surfaces — so