diff --git a/Flowlight/Inspection/InspectionController.swift b/Flowlight/Inspection/InspectionController.swift index 835360a..e7a5213 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 0000000..e0ee605 --- /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 53ada31..73dbe8c 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 0000000..f3a7f02 --- /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 0000000..bf28c02 --- /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 a4cf886..f871b1f 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 f3d86c1..7bd1646 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 232501c..f231b8e 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 bdda1dd..435ac39 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 20a5406..9aca252 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 c0a785f..822d7b2 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 760802e..b01b8ab 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 d672fe3..c71c6e0 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 2b82374..d689032 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 eb9f599..0bf4de8 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 b2c7521..ac26d22 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 6b04367..8ebc5f0 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 5be27b9..6968170 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 e4819a7..32bdea2 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 87e1535..fb2bb29 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 641c61e..339245e 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 5a7f9b8..611c048 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 e48f9d3..7276be5 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 d151de1..53dd98b 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 a2aac2d..a4d95f2 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 a8d3b8e..bc81260 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 0cf14e7..81876b2 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 1bcb9bf..e87a59d 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 93a01c5..5a581a6 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 3c1bef7..bc4b631 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 28d432c..7f2e472 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 b024b04..39177ec 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 00be2b5..a5e9af2 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 ac5e780..23a38ab 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 aa21453..0ec3545 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 48e97da..c0f4ccb 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 bf59205..b62e2f1 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 2992c1c..3127e95 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 e319b56..4eb0ce1 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 7f3a4f0..c5a5d29 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 d816b8a..106f436 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 d466c59..d057e20 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 811e669..53ae95f 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 c7ea1a7..9ab3862 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 3e7ffb8..beb2c2b 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 2b288b5..8fd8b11 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 381a09d..8766507 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 bac6941..7ccfb5b 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 23b47e0..dae7536 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 e45d453..937520b 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 0d7d860..0d1f191 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 8ed0020..ecfa416 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 f2f6de4..5cbeb77 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 68436f1..8b3ae14 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 aab50f5..7567a16 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 357a694..ae95046 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 1c425c5..ff1f7dc 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 b5d07a5..47574b3 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 a07830e..ed46a27 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 51ccfaa..7b1cfac 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 68c3fb4..a57e66a 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 13005da..272b14c 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