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
44 changes: 42 additions & 2 deletions Flowlight/Inspection/InspectionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ final class InspectionController: ObservableObject {
static let mockRules = "inspection.mockRules"
/// Rules that rewrite an outgoing request's headers or JSON body before it is forwarded (see `RewriteRule`).
static let rewriteRules = "inspection.rewriteRules"
/// Scripts that change eligible upstream responses after the origin answered (see `ResponseTransformRule`).
static let responseTransformRules = "inspection.responseTransformRules"
/// When the running session was switched on, so its end survives a relaunch.
static let sessionStarted = "inspection.sessionStarted"
/// Names of the agents the user asked Flowlight to keep routed through the proxy by editing their own
Expand Down Expand Up @@ -98,6 +100,7 @@ final class InspectionController: ObservableObject {
}
let mockRules = { Self.decodeMockRules(UserDefaults.standard.data(forKey: Keys.mockRules)) }
let rewriteRules = { Self.decodeRewriteRules(UserDefaults.standard.data(forKey: Keys.rewriteRules)) }
let responseTransforms = { Self.decodeResponseTransformRules(UserDefaults.standard.data(forKey: Keys.responseTransformRules)) }
// A rule refusing a request is answered by the same machinery that gives a mock its canned response, and
// it goes first: a block someone wrote has to outrank a mock they left switched on.
let answersFor = { [weak self, recorder, proxy] (host: String, clientPort: UInt16) -> [MockRule] in
Expand Down Expand Up @@ -161,6 +164,25 @@ final class InspectionController: ObservableObject {
return .replace(current, note: notes.joined(separator: " · "))
}
}
proxy.responseInterventions = { flow in
let rules = ResponseTransformRules.matching(responseTransforms(), host: flow.host)
guard !rules.isEmpty else { return nil }
return ResponseGate.Intervention(
transform: { context, responseHead, responseBody in
let applicable = ResponseTransformRules.applicable(rules, host: flow.host, method: context.head.method, path: context.head.target)
guard !applicable.isEmpty else { return nil }
// The runner fails open: an unavailable helper, invalid script, bad output, or timeout leaves the
// upstream bytes untouched instead of making an application fail because its diagnostic rule did.
guard let replacement = ResponseScriptRunner.shared.transform(rules: applicable,
request: ResponseTransformRequest(head: context.head, host: flow.host, scheme: flow.scheme, port: flow.port),
responseHead: responseHead, responseBody: responseBody) else { return nil }
return ResponseGate.Replacement(body: replacement.body, note: replacement.note)
},
shouldTransform: { context in
!ResponseTransformRules.applicable(rules, host: flow.host, method: context.head.method, path: context.head.target).isEmpty
}
)
}
proxy.onAnswered = { [weak self, recorder, proxy] rule, flow, head in
guard rule.blocked, let refused = (self?.requestRules() ?? []).first(where: { $0.id == rule.id }) else { return }
let owner = recorder.owner(clientPort: flow.clientPort, proxyPort: proxy.port)
Expand All @@ -179,8 +201,9 @@ final class InspectionController: ObservableObject {
// A host someone wrote a mock rule for is decrypted whatever the scope says: a rule can only answer a
// request Flowlight can read, and "my mock didn't fire" is a bad afternoon.
guard answersFor(host, clientPort).isEmpty else { answer(true); return }
// Same for a host with a rewrite rule: it can only edit a request Flowlight can read.
// Same for a host with a rewrite or response-transform rule: it can only edit bytes Flowlight can read.
guard RewriteRules.matching(rewriteRules(), host: host).isEmpty else { answer(true); return }
guard ResponseTransformRules.matching(responseTransforms(), host: host).isEmpty else { answer(true); return }
guard scope == .agents else { answer(true); return }
decide.async {
answer(recorder.owner(clientPort: clientPort, proxyPort: proxy.port).agent != nil)
Expand All @@ -190,7 +213,7 @@ final class InspectionController: ObservableObject {
// Plain-HTTP requests reach the recorder regardless of scope; keep only what the scope allows.
let (scope, _) = scopeAndList()
// A mocked exchange is always kept: an answer Flowlight invented has to be visible wherever it lands.
guard scope == .all || exchange.agent != nil || exchange.note != nil || exchange.mockRule != nil else { return }
guard scope == .all || exchange.agent != nil || exchange.note != nil || exchange.mockRule != nil || exchange.responseTransform != nil else { return }
guard let self else { return }
Task { @MainActor in
// Capture is committed first. The database transaction optionally creates a tiny derived candidate;
Expand Down Expand Up @@ -314,6 +337,23 @@ final class InspectionController: ObservableObject {
return (try? JSONDecoder().decode([RewriteRule].self, from: data)) ?? []
}

/// Scripts that change a real server response before the client sees it. Like other inspection rules, these are
/// settings rather than captured traffic and survive clearing the inspection database.
var responseTransformRules: [ResponseTransformRule] {
get { Self.decodeResponseTransformRules(UserDefaults.standard.data(forKey: Keys.responseTransformRules)) }
set {
UserDefaults.standard.set(try? JSONEncoder().encode(newValue), forKey: Keys.responseTransformRules)
objectWillChange.send()
}
}

var activeResponseTransformRules: Int { responseTransformRules.filter(\.enabled).count }

nonisolated static func decodeResponseTransformRules(_ data: Data?) -> [ResponseTransformRule] {
guard let data else { return [] }
return (try? JSONDecoder().decode([ResponseTransformRule].self, from: data)) ?? []
}

var configuredPort: UInt16 { UInt16(clamping: max(1024, UserDefaults.standard.integer(forKey: Keys.port))) }

func attach(db: TrafficDatabase) {
Expand Down
32 changes: 29 additions & 3 deletions Flowlight/Inspection/InspectionProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ protocol ProxyObserver: AnyObject {
/// The request whose bytes come next left with something taken out of it — a guardrail removing tools an
/// agent is not allowed to use. What is recorded is what the server was sent, which is what happened.
func flow(_ flow: ProxyFlow, guardedBy note: String)
/// The upstream response bytes that come next were changed before the client received them. This is deliberately
/// distinct from a mock (which never contacted the origin) and a guardrail (which changed the request).
func flow(_ flow: ProxyFlow, responseTransformedBy note: String)
}

/// A local HTTP proxy on 127.0.0.1 that can decrypt HTTPS for inspection.
Expand Down Expand Up @@ -65,6 +68,9 @@ final class InspectionProxy: @unchecked Sendable {
/// Asked once per connection: is there anything that would want to read, change or answer whole requests on
/// it? Nil means every request on this connection streams untouched, which is what almost all of them do.
var interventions: (_ host: String, _ clientPort: UInt16) -> ((ProxyRequestHead, Data) -> MockGate.Intervention?)? = { _, _ in nil }
/// Response transforms are separate from request interventions: their gate observes only bytes actually sent
/// upstream and can replace a bounded upstream response before it reaches both the client and recorder.
var responseInterventions: (_ flow: ProxyFlow) -> ResponseGate.Intervention? = { _ in nil }
/// Served at http://127.0.0.1:<port>/proxy.pac.
var pacScript: () -> String = { "function FindProxyForURL(url, host) { return \"DIRECT\"; }" }
var onStateChange: (String?) -> Void = { _ in }
Expand Down Expand Up @@ -321,10 +327,30 @@ final class InspectionProxy: @unchecked Sendable {
let intervene = interventions(flow.host, flow.clientPort)
let gate = mocks.isEmpty && intervene == nil ? nil : MockGate(host: flow.host, rules: mocks)
gate?.intervene = intervene
let responseTransform = responseInterventions(flow)
let responseGate = responseTransform.map { ResponseGate(transform: $0.transform, shouldTransform: $0.shouldTransform) }
let fromClient: (Data) -> Data = { [weak self] data in
guard let self else { return data }
guard let gate else { self.observer?.flow(flow, clientSent: data); return data }
return self.apply(gate.clientSent(data), flow: flow, client: client)
guard let gate else {
self.observer?.flow(flow, clientSent: data)
responseGate?.clientSent(data)
return data
}
let onward = self.apply(gate.clientSent(data), flow: flow, client: client)
responseGate?.clientSent(onward)
return onward
}
let fromServer: (Data) -> Data = { [weak self] data in
guard let self else { return data }
guard let responseGate else { self.observer?.flow(flow, serverSent: data); return data }
let actions = responseGate.serverSent(data)
var onward = Data()
for (bytes, note) in actions {
if let note { self.observer?.flow(flow, responseTransformedBy: note) }
self.observer?.flow(flow, serverSent: bytes)
onward.append(bytes)
}
return onward
}
var ended = false
let finish: (String?) -> Void = { [weak self] note in
Expand All @@ -343,7 +369,7 @@ final class InspectionProxy: @unchecked Sendable {
if !onward.isEmpty { upstream.send(content: onward, completion: .idempotent) }
}
self.pump(client, into: upstream, tap: fromClient) { finish(nil) }
self.pump(upstream, into: client, tap: { self.observer?.flow(flow, serverSent: $0); return $0 }) { finish(nil) }
self.pump(upstream, into: client, tap: fromServer) { finish(nil) }
case .failed(let error):
finish("Couldn't reach \(flow.host): \(error.localizedDescription)")
case .waiting(let error):
Expand Down
17 changes: 13 additions & 4 deletions Flowlight/Inspection/InspectionRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ struct HTTPExchange: Identifiable, Equatable, Sendable {
/// What a guardrail took out of this request before it left. Its own field for the same reason: the exchange
/// is real and was really sent, but it is not quite what the agent wrote, and that has to be readable.
var guardrail: String?
/// The rule that changed a real upstream response before the client received it. A mock is intentionally not
/// reused here: a transformed exchange really reached its origin.
var responseTransform: String? = nil

var url: String {
let defaultPort = (scheme == "https" && port == 443) || (scheme == "http" && port == 80)
Expand Down Expand Up @@ -108,7 +111,7 @@ enum SocketOwner {
/// them to the process (and agent) behind the connection, reads tool calls, redacts credentials, and hands each
/// finished exchange to `onExchange`.
final class InspectionRecorder: ProxyObserver, @unchecked Sendable {
private struct Pending { var head: HTTPHead; var body: HTTPBody; var started: Date; var mock: String?; var guardrail: String? }
private struct Pending { var head: HTTPHead; var body: HTTPBody; var started: Date; var mock: String?; var guardrail: String?; var responseTransform: String? }

private final class FlowState {
let request: HTTPStreamParser
Expand All @@ -117,6 +120,9 @@ final class InspectionRecorder: ProxyObserver, @unchecked Sendable {
/// Set by the proxy just before the bytes that complete a request it answers itself.
var nextMock: String?
var nextGuardrail: String?
/// Marked by the proxy immediately before the transformed response is fed here. The oldest pending request
/// is the response being delivered, including on a pipelined keep-alive connection.
var nextResponseTransform: String?
var owner: Owner?
let ownerReady = DispatchSemaphore(value: 0)
init(limit: Int) {
Expand Down Expand Up @@ -156,13 +162,15 @@ final class InspectionRecorder: ProxyObserver, @unchecked Sendable {
state.request.onHead = { response.requestMethods.append($0.method) }
state.request.onMessage = { [weak state] head, body in
state?.queue.append(Pending(head: head, body: body, started: Date(), mock: state?.nextMock,
guardrail: state?.nextGuardrail))
guardrail: state?.nextGuardrail, responseTransform: nil))
state?.nextMock = nil
state?.nextGuardrail = nil
}
state.response.onMessage = { [weak self, weak state] head, body in
guard let self, let state, !state.queue.isEmpty else { return }
let request = state.queue.removeFirst()
var request = state.queue.removeFirst()
request.responseTransform = state.nextResponseTransform
state.nextResponseTransform = nil
self.emit(flow: flow, state: state, request: request, responseHead: head, responseBody: body, note: nil)
}
flows[flow.id] = state
Expand All @@ -187,6 +195,7 @@ final class InspectionRecorder: ProxyObserver, @unchecked Sendable {
func flow(_ flow: ProxyFlow, serverSent data: Data) { flows[flow.id]?.response.feed(data) }
func flow(_ flow: ProxyFlow, mockedBy rule: String) { flows[flow.id]?.nextMock = rule }
func flow(_ flow: ProxyFlow, guardedBy note: String) { flows[flow.id]?.nextGuardrail = note }
func flow(_ flow: ProxyFlow, responseTransformedBy note: String) { flows[flow.id]?.nextResponseTransform = note }

func flowEnded(_ flow: ProxyFlow, note: String?) {
guard let state = flows.removeValue(forKey: flow.id) else { return }
Expand Down Expand Up @@ -247,7 +256,7 @@ final class InspectionRecorder: ProxyObserver, @unchecked Sendable {
contentType: request.head.value("Content-Type") ?? responseHead?.value("Content-Type") ?? "", pid: owner.pid, bundleID: owner.bundleID, appName: owner.appName,
agent: owner.agent, agentName: owner.agentName, mcpServer: owner.mcpServer, toolCalls: calls,
toolResults: results, mcp: mcp, llm: llm, note: notes.isEmpty ? nil : notes.joined(separator: " "),
mockRule: request.mock, guardrail: request.guardrail)
mockRule: request.mock, guardrail: request.guardrail, responseTransform: request.responseTransform)
onExchange(exchange)
}
}
Expand Down
4 changes: 3 additions & 1 deletion Flowlight/Inspection/MockRule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ struct MockRule: Codable, Equatable, Identifiable, Sendable {

/// A rule prefilled from a recorded exchange, so "mock this" starts from the request that was actually made.
init(mocking exchange: HTTPExchange) {
let endpoint = exchange.path.split(separator: "?").first.map(String.init) ?? "/"
name = "Mock \(exchange.method.uppercased()) \(exchange.host)\(endpoint)"
host = exchange.host
path = exchange.path.split(separator: "?").first.map(String.init) ?? "/"
path = endpoint
method = exchange.method
status = 500
body = #"{"error": "mocked by Flowlight"}"#
Expand Down
Loading
Loading