Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 53 additions & 16 deletions Flowlight/Inspection/InspectionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
192 changes: 192 additions & 0 deletions Flowlight/Inspection/RewriteRule.swift
Original file line number Diff line number Diff line change
@@ -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..<sep.lowerBound], as: UTF8.self)
var lines = headText.components(separatedBy: "\r\n")
guard !lines.isEmpty else { return nil }
let requestLine = lines.removeFirst() // method/target/version are never rewritten
var headerLines = lines
var bodyData = Data(request[sep.upperBound...])
var notes: [String] = []

for edit in applicable.flatMap(\.headers) {
// Sanitise both halves: a stray newline in a header value would forge extra headers or a second request.
let name = MockRule.headerSafe(edit.name)
guard !name.isEmpty else { continue }
let value = MockRule.headerSafe(edit.value)
let prefix = name.lowercased() + ":"
switch edit.op {
case .remove:
let before = headerLines.count
headerLines.removeAll { $0.lowercased().hasPrefix(prefix) }
if headerLines.count != before { notes.append("removed \(name)") }
case .set:
headerLines.removeAll { $0.lowercased().hasPrefix(prefix) }
headerLines.append("\(name): \(value)")
notes.append("set \(name)")
case .add:
headerLines.append("\(name): \(value)")
notes.append("added \(name)")
}
}

let bodyEdits = applicable.flatMap(\.body)
if !bodyEdits.isEmpty, !bodyData.isEmpty,
var json = (try? JSONSerialization.jsonObject(with: bodyData)) as? [String: Any] {
var changed = false
for edit in bodyEdits {
let comps = edit.path.split(separator: ".").map(String.init)
guard !comps.isEmpty else { continue }
switch edit.op {
case .set: setJSON(&json, path: comps, value: parseValue(edit.value)); changed = true; notes.append("set \(edit.path)")
case .remove: removeJSON(&json, path: comps); changed = true; notes.append("removed \(edit.path)")
}
}
if changed, let out = try? JSONSerialization.data(withJSONObject: json) { bodyData = out }
}

guard !notes.isEmpty else { return nil }

// Reframe with a Content-Length that agrees with the final body, or the connection would hang.
headerLines.removeAll { $0.lowercased().hasPrefix("content-length:") }
if !bodyData.isEmpty { headerLines.append("Content-Length: \(bodyData.count)") }
var out = Data(([requestLine] + headerLines).joined(separator: "\r\n").utf8)
out.append(Data("\r\n\r\n".utf8))
out.append(bodyData)
return (out, notes.joined(separator: ", "))
}

/// Parse a value cell as JSON when it can be (number, bool, null, object, array, quoted string); otherwise take
/// it literally as a string. Wrapped in an array so a bare scalar parses.
static func parseValue(_ s: String) -> 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
}
}
14 changes: 14 additions & 0 deletions Flowlight/UI/InspectView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading