Files
luma/Sources/LumaCore/Agentic/MissionTools.swift

4218 lines
228 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Frida
import SwiftyR2
@MainActor
public enum MissionTools {
public static let resultByteCap = 16 * 1024
public static let stringValueByteCap = 2 * 1024
public static let requestUserInputToolName = "request_user_input"
public static func registerStandard(in catalog: ToolCatalog, engine: Engine) {
registerListDevices(in: catalog, engine: engine)
registerListProcesses(in: catalog, engine: engine)
registerListSessions(in: catalog, engine: engine)
registerAttachToProcess(in: catalog, engine: engine)
registerSpawnProcess(in: catalog, engine: engine)
registerListModules(in: catalog, engine: engine)
registerListThreads(in: catalog, engine: engine)
registerListSessionInstruments(in: catalog, engine: engine)
registerArmSession(in: catalog, engine: engine)
registerDisarmSession(in: catalog, engine: engine)
registerResumeSession(in: catalog, engine: engine)
registerSummarizeRecentEvents(in: catalog, engine: engine)
registerWaitForEvent(in: catalog, engine: engine)
registerReadEvent(in: catalog, engine: engine)
registerResolveSymbol(in: catalog, engine: engine)
registerDisassemble(in: catalog, engine: engine)
registerR2Cmd(in: catalog, engine: engine)
registerDecompile(in: catalog, engine: engine)
registerExplainFunction(in: catalog, engine: engine)
registerSuggestFunctionName(in: catalog, engine: engine)
registerSuggestFunctionSignature(in: catalog, engine: engine)
registerSuggestLocalNames(in: catalog, engine: engine)
registerFindVulnerabilities(in: catalog, engine: engine)
registerReadMemory(in: catalog, engine: engine)
registerWriteMemory(in: catalog, engine: engine)
registerRecordFinding(in: catalog, engine: engine)
registerListFindings(in: catalog, engine: engine)
registerReadFinding(in: catalog, engine: engine)
registerListNotebookEntries(in: catalog, engine: engine)
registerReadNotebookEntry(in: catalog, engine: engine)
registerCreateNotebookEntry(in: catalog, engine: engine)
registerUpdateNotebookEntry(in: catalog, engine: engine)
registerDeleteNotebookEntry(in: catalog, engine: engine)
registerEvalREPL(in: catalog, engine: engine)
registerInstallTracerHook(in: catalog, engine: engine)
registerListTracerHooks(in: catalog, engine: engine)
registerReadTracerHook(in: catalog, engine: engine)
registerUpdateTracerHook(in: catalog, engine: engine)
registerEditTracerHook(in: catalog, engine: engine)
registerRemoveTracerHook(in: catalog, engine: engine)
registerListInstruments(in: catalog, engine: engine)
registerAttachInstrument(in: catalog, engine: engine)
registerReadCustomInstrument(in: catalog, engine: engine)
registerCreateCustomInstrument(in: catalog, engine: engine)
registerUpdateCustomInstrument(in: catalog, engine: engine)
registerDeleteCustomInstrument(in: catalog, engine: engine)
registerListCustomInstrumentFiles(in: catalog, engine: engine)
registerReadCustomInstrumentFile(in: catalog, engine: engine)
registerWriteCustomInstrumentFile(in: catalog, engine: engine)
registerEditCustomInstrumentFile(in: catalog, engine: engine)
registerDeleteCustomInstrumentFile(in: catalog, engine: engine)
registerRenameCustomInstrumentFile(in: catalog, engine: engine)
registerSetCustomInstrumentEntrypoint(in: catalog, engine: engine)
registerReadTracerHandlerTemplate(in: catalog)
registerReadCustomInstrumentTemplate(in: catalog)
registerReadCustomInstrumentTypings(in: catalog, engine: engine)
registerLookupFridaAPI(in: catalog)
registerListPackages(in: catalog, engine: engine)
registerInstallPackage(in: catalog, engine: engine)
registerRemovePackage(in: catalog, engine: engine)
registerStartThreadTrace(in: catalog, engine: engine)
registerStopTrace(in: catalog, engine: engine)
registerListTraces(in: catalog, engine: engine)
registerSummarizeTrace(in: catalog, engine: engine)
registerListTraceFunctionCalls(in: catalog, engine: engine)
registerReadTraceFunctionCall(in: catalog, engine: engine)
registerReadTraceRegisterState(in: catalog, engine: engine)
registerPinAsInsight(in: catalog, engine: engine)
registerListAddressInsights(in: catalog, engine: engine)
registerUnpinInsight(in: catalog, engine: engine)
registerRenameInsight(in: catalog, engine: engine)
registerPatterns(in: catalog, engine: engine)
registerVirtualMachines(in: catalog, engine: engine)
registerDetachSession(in: catalog, engine: engine)
registerReadWidgetState(in: catalog, engine: engine)
registerInvokeWidgetAction(in: catalog, engine: engine)
registerSubmitConsoleInput(in: catalog, engine: engine)
registerRequestUserInput(in: catalog)
registerPharo(in: catalog, engine: engine)
}
private static func registerListDevices(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "list_devices",
description: "List devices reachable to Frida (local, USB-attached, network). Use when no existing session fits the goal and you need to find a target.",
inputSchemaJSON: """
{"type":"object","properties":{},"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] _ in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
let devices = await engine.deviceManager.currentDevices()
let array: [[String: Any]] = devices.map { d in
[
"id": d.id,
"name": d.name,
"kind": String(describing: d.type),
"is_lost": d.isLost,
]
}
return makeResult(jsonObject: array, summary: "Listed \(devices.count) device\(devices.count == 1 ? "" : "s")")
}
}
private static func registerListProcesses(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "list_processes",
description: "List running processes on a Frida device. Pass name_pattern (case-insensitive regex on the process name) to narrow the result; omit it to browse all processes. Use after list_devices when looking for a pid to attach to.",
inputSchemaJSON: """
{"type":"object","properties":{"device_id":{"type":"string"},"name_pattern":{"type":"string","description":"Case-insensitive regex matched against process names. Omit to return everything."},"scope":{"type":"string","enum":["minimal","metadata","full"],"default":"minimal","description":"metadata adds parameters; full also adds icons (slower)"}},"required":["device_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let deviceID = invocation.args["device_id"] as? String else {
return errorResult("missing device_id", code: .invalidInput)
}
let devices = await engine.deviceManager.currentDevices()
guard let device = devices.first(where: { $0.id == deviceID }) else {
return errorResult("no device with id \(deviceID)", code: .notFound)
}
let scope = parseProcessScope(invocation.args["scope"] as? String)
let patternString = (invocation.args["name_pattern"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let regex: Regex<AnyRegexOutput>?
if let patternString, !patternString.isEmpty {
do {
regex = try Regex(patternString).ignoresCase()
} catch {
return errorResult("invalid name_pattern: \(error.localizedDescription)", code: .invalidInput)
}
} else {
regex = nil
}
do {
let processes = try await device.enumerateProcesses(scope: scope)
let matches: [ProcessDetails]
if let regex {
matches = processes.filter { $0.name.contains(regex) }
} else {
matches = processes
}
let array: [[String: Any]] = matches.map { p in
["pid": p.pid, "name": p.name]
}
let payload: [String: Any] = [
"matches": array,
"match_count": matches.count,
"total_scanned": processes.count,
]
let summary = describeProcessMatchSummary(
matchCount: matches.count,
totalScanned: processes.count,
pattern: patternString,
deviceName: device.name
)
return makeResult(jsonObject: payload, summary: summary)
} catch {
return errorResult("enumerate failed: \(error.localizedDescription)")
}
}
}
private static func describeProcessMatchSummary(matchCount: Int, totalScanned: Int, pattern: String?, deviceName: String) -> String {
let processWord = matchCount == 1 ? "process" : "processes"
if let pattern, !pattern.isEmpty {
return "Matched \(matchCount) \(processWord) of \(totalScanned) on \(deviceName) (pattern: \(pattern))"
}
return "Found \(totalScanned) \(processWord) on \(deviceName)"
}
private static func registerListSessions(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "list_sessions",
description: "List sessions (attached processes) in this project. Returns id, process name, device, and whether the session is currently attached.",
inputSchemaJSON: """
{"type":"object","properties":{},"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] _ in
guard let engine else { return ActionResult(summary: "engine unavailable", resultJSON: "[]", isError: true) }
let sessions = engine.sessions
let array: [[String: Any]] = sessions.map { s in
var entry: [String: Any] = [
"id": s.id.uuidString,
"process_name": s.processName,
"device_id": s.deviceID,
"device_name": s.deviceName,
"phase": phaseDescription(s.phase),
"last_known_pid": s.lastKnownPID,
]
if let error = s.lastError, !error.isEmpty {
entry["last_error"] = error
}
return entry
}
return makeResult(jsonObject: array, summary: "Found \(sessions.count) session\(sessions.count == 1 ? "" : "s")")
}
}
private static func registerAttachToProcess(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "attach_to_process",
description: "Attach Frida to an already-running process by pid. Idempotent: if a session for the same device and pid already exists, the existing session is reused (re-attaching when needed) instead of creating a duplicate. Requires user approval.",
inputSchemaJSON: """
{"type":"object","properties":{"device_id":{"type":"string"},"pid":{"type":"integer","minimum":1}},"required":["device_id","pid"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let deviceID = invocation.args["device_id"] as? String else {
return errorResult("missing device_id", code: .invalidInput)
}
guard let pidNumber = invocation.args["pid"] as? Int, pidNumber > 0 else {
return errorResult("missing or invalid pid", code: .invalidInput)
}
let pid = UInt(pidNumber)
let devices = await engine.deviceManager.currentDevices()
guard let device = devices.first(where: { $0.id == deviceID }) else {
return errorResult("no device with id \(deviceID)", code: .notFound)
}
do {
let processes = try await device.enumerateProcesses(pids: [pid], scope: .full)
guard let process = processes.first else {
return errorResult("pid \(pid) not found on \(device.name)", code: .notFound)
}
if let existing = findExistingAttach(in: engine, deviceID: device.id, pid: pid) {
return await reuseAttachSession(existing, engine: engine, device: device, process: process)
}
let session = engine.prepareAttachSession(device: device, process: process)
do {
let attached = try await engine.attach(device: device, process: process, session: session)
return attachSuccessResult(session: attached, process: process, device: device, reused: false, successVerb: "Attached")
} catch {
return errorResult("attach failed for \(process.name) (pid \(pid)) on \(device.name): \(error.localizedDescription)")
}
} catch {
return errorResult("attach failed: \(error.localizedDescription)")
}
}
}
private static func registerSpawnProcess(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "spawn_process",
description: "Spawn a process under Frida and attach. target_kind=program takes a 'path'; target_kind=application takes an 'identifier' (bundle ID on Apple, package name on Android). auto_resume defaults to true. Idempotent: if a session for the same device and target already exists, the existing session is reused (re-spawning when needed) instead of creating a duplicate. Requires user approval.",
inputSchemaJSON: """
{"type":"object","properties":{"device_id":{"type":"string"},"target_kind":{"type":"string","enum":["program","application"]},"path":{"type":"string"},"identifier":{"type":"string"},"name":{"type":"string"},"arguments":{"type":"array","items":{"type":"string"}},"environment":{"type":"object","additionalProperties":{"type":"string"}},"working_directory":{"type":"string"},"auto_resume":{"type":"boolean","default":true}},"required":["device_id","target_kind"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let deviceID = invocation.args["device_id"] as? String else {
return errorResult("missing device_id", code: .invalidInput)
}
let devices = await engine.deviceManager.currentDevices()
guard let device = devices.first(where: { $0.id == deviceID }) else {
return errorResult("no device with id \(deviceID)", code: .notFound)
}
guard let kind = invocation.args["target_kind"] as? String else {
return errorResult("missing target_kind", code: .invalidInput)
}
let target: SpawnConfig.Target
switch kind {
case "program":
guard let path = invocation.args["path"] as? String, !path.isEmpty else {
return errorResult("program target requires non-empty 'path'", code: .invalidInput)
}
target = .program(path: path)
case "application":
guard let identifier = invocation.args["identifier"] as? String, !identifier.isEmpty else {
return errorResult("application target requires non-empty 'identifier'", code: .invalidInput)
}
let displayName = (invocation.args["name"] as? String) ?? identifier
target = .application(identifier: identifier, name: displayName, iconPNGData: nil)
default:
return errorResult("unknown target_kind: \(kind)", code: .invalidInput)
}
let arguments = (invocation.args["arguments"] as? [String]) ?? []
let environment = (invocation.args["environment"] as? [String: String]) ?? [:]
let workingDirectory = invocation.args["working_directory"] as? String
let autoResume = (invocation.args["auto_resume"] as? Bool) ?? true
let config = SpawnConfig(
target: target,
arguments: arguments,
environment: environment,
workingDirectory: workingDirectory,
stdio: .inherit,
autoResume: autoResume
)
if let existing = findExistingSpawn(in: engine, deviceID: device.id, target: target) {
return await reuseSpawnSession(existing, engine: engine, device: device, config: config)
}
let session = engine.prepareSpawnSession(device: device, config: config)
do {
let attached = try await engine.spawnAndAttach(device: device, session: session)
return spawnSuccessResult(session: attached, processName: config.defaultDisplayName, device: device, autoResume: autoResume, reused: false, successVerb: "Spawned")
} catch {
return errorResult("spawn failed for \(config.defaultDisplayName) on \(device.name): \(error.localizedDescription)")
}
}
}
private static func findExistingAttach(in engine: Engine, deviceID: String, pid: UInt) -> ProcessSession? {
engine.sessions.first { session in
guard case .attach = session.kind else { return false }
return session.deviceID == deviceID && session.lastKnownPID == pid
}
}
private static func reuseAttachSession(_ session: ProcessSession, engine: Engine, device: Device, process: ProcessDetails) async -> ActionResult {
if engine.node(forSessionID: session.id) != nil {
return attachSuccessResult(session: session, process: process, device: device, reused: true, successVerb: "Already attached")
}
do {
let attached = try await engine.attach(device: device, process: process, session: session)
return attachSuccessResult(session: attached, process: process, device: device, reused: true, successVerb: "Re-attached")
} catch {
return errorResult("attach failed for \(process.name) (pid \(process.pid)) on \(device.name): \(error.localizedDescription)")
}
}
private static func findExistingSpawn(in engine: Engine, deviceID: String, target: SpawnConfig.Target) -> ProcessSession? {
engine.sessions.first { session in
guard case .spawn(let cfg) = session.kind else { return false }
return session.deviceID == deviceID && spawnTargetsMatch(cfg.target, target)
}
}
private static func reuseSpawnSession(_ session: ProcessSession, engine: Engine, device: Device, config: SpawnConfig) async -> ActionResult {
if engine.node(forSessionID: session.id) != nil {
return spawnSuccessResult(session: session, processName: session.processName, device: device, autoResume: config.autoResume, reused: true, successVerb: "Already attached to")
}
do {
let attached = try await engine.spawnAndAttach(device: device, session: session)
return spawnSuccessResult(session: attached, processName: session.processName, device: device, autoResume: config.autoResume, reused: true, successVerb: "Re-spawned")
} catch {
return errorResult("spawn failed for \(session.processName) on \(device.name): \(error.localizedDescription)")
}
}
private static func attachSuccessResult(session: ProcessSession, process: ProcessDetails, device: Device, reused: Bool, successVerb: String) -> ActionResult {
var payload: [String: Any] = [
"session_id": session.id.uuidString,
"process_name": process.name,
"pid": process.pid,
"phase": phaseDescription(session.phase),
]
if reused { payload["reused"] = true }
return makeResult(jsonObject: payload, summary: "\(successVerb) to \(process.name) (pid \(process.pid)) on \(device.name)")
}
private static func spawnSuccessResult(session: ProcessSession, processName: String, device: Device, autoResume: Bool, reused: Bool, successVerb: String) -> ActionResult {
var payload: [String: Any] = [
"session_id": session.id.uuidString,
"process_name": processName,
"auto_resume": autoResume,
"phase": phaseDescription(session.phase),
"pid": session.lastKnownPID,
]
if reused { payload["reused"] = true }
return makeResult(jsonObject: payload, summary: "\(successVerb) \(processName) on \(device.name)")
}
private static func phaseDescription(_ phase: ProcessSession.Phase) -> String {
switch phase {
case .idle: return "idle"
case .attaching: return "attaching"
case .awaitingInitialResume: return "awaiting_initial_resume"
case .attached: return "attached"
}
}
private static func spawnTargetsMatch(_ a: SpawnConfig.Target, _ b: SpawnConfig.Target) -> Bool {
switch (a, b) {
case (.program(let p1), .program(let p2)):
return p1 == p2
case (.application(let id1, _, _), .application(let id2, _, _)):
return id1 == id2
default:
return false
}
}
private static func registerListModules(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "list_modules",
description: "List loaded modules (libraries, frameworks, main binary) in the target process. Real processes can have ~1000 modules, so the result is filtered and capped by default. Pass 'match' (case-insensitive substring matched against name and path) to narrow, 'offset'/'limit' to page, and 'detail' to choose projection. Default detail 'summary' returns {name, base}; 'full' adds size and path. Response shape: {total, matched, offset, returned, truncated, modules}.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string","description":"Session UUID to query"},"match":{"type":"string","description":"Case-insensitive substring matched against module name and path. Omit to consider all modules."},"offset":{"type":"integer","minimum":0,"description":"Number of matched modules to skip before returning rows (default 0)."},"limit":{"type":"integer","minimum":1,"maximum":500,"description":"Max modules to return (default 64)"},"detail":{"type":"string","enum":["summary","full"],"description":"'summary' = name+base only (default). 'full' adds size and path."}},"required":["session_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let node = engine.node(forSessionID: sessionID) else {
return errorResult("no attached session for id \(sessionID)", code: .notFound)
}
let all = node.modules
let needle = (invocation.args["match"] as? String).flatMap { $0.isEmpty ? nil : $0 }
let matched: [ProcessModule]
if let needle {
let lowered = needle.lowercased()
matched = all.filter { $0.name.lowercased().contains(lowered) || $0.path.lowercased().contains(lowered) }
} else {
matched = all
}
let offset = max(0, (invocation.args["offset"] as? Int) ?? 0)
let limit = max(1, min(500, (invocation.args["limit"] as? Int) ?? 64))
let returned = Array(matched.dropFirst(offset).prefix(limit))
let truncated = matched.count > offset + returned.count
let detail = (invocation.args["detail"] as? String) ?? "summary"
let modulesJSON: [[String: Any]] = returned.map { m in
var entry: [String: Any] = [
"name": m.name,
"base": String(format: "0x%llx", m.base),
]
if detail == "full" {
entry["size"] = m.size
entry["path"] = m.path
}
return entry
}
let payload: [String: Any] = [
"total": all.count,
"matched": matched.count,
"offset": offset,
"returned": returned.count,
"truncated": truncated,
"modules": modulesJSON,
]
let summary: String
if let needle {
summary = "Matched \(matched.count)/\(all.count) module\(matched.count == 1 ? "" : "s") for '\(needle)'\(truncated ? " (returning \(returned.count))" : "")"
} else {
summary = "\(all.count) module\(all.count == 1 ? "" : "s") loaded\(truncated ? "; returning \(returned.count) from offset \(offset)" : "")"
}
return makeResult(jsonObject: payload, summary: summary)
}
}
private static func registerListThreads(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "list_threads",
description: "List the target process's known threads. Thread-heavy processes can have hundreds of rows, so the result is capped by default. Pass 'offset'/'limit' to page and 'detail' to choose projection. Default detail 'summary' returns {id, name}; 'full' also includes entrypoint. Live for attached sessions; falls back to the last cached snapshot when the session is detached. Response shape: {total, offset, returned, truncated, threads}.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"offset":{"type":"integer","minimum":0,"description":"Number of threads to skip before returning rows (default 0)."},"limit":{"type":"integer","minimum":1,"maximum":500,"description":"Max threads to return (default 64)."},"detail":{"type":"string","enum":["summary","full"],"description":"'summary' = id+name only (default). 'full' adds entrypoint."}},"required":["session_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
let liveThreads = engine.node(forSessionID: sessionID)?.threads
let cached = engine.sessions.first(where: { $0.id == sessionID })?.lastKnownThreads
guard let threads = liveThreads ?? cached else {
return errorResult("no thread data for session \(sessionID)", code: .notFound)
}
let offset = max(0, (invocation.args["offset"] as? Int) ?? 0)
let limit = max(1, min(500, (invocation.args["limit"] as? Int) ?? 64))
let returned = Array(threads.dropFirst(offset).prefix(limit))
let truncated = threads.count > offset + returned.count
let detail = (invocation.args["detail"] as? String) ?? "summary"
let array: [[String: Any]] = returned.map { threadJSON($0, detail: detail) }
let payload: [String: Any] = [
"total": threads.count,
"offset": offset,
"returned": returned.count,
"truncated": truncated,
"threads": array,
]
let suffix = truncated ? "; returning \(returned.count) from offset \(offset)" : ""
return makeResult(jsonObject: payload, summary: "Listed \(threads.count) thread\(threads.count == 1 ? "" : "s")\(suffix)")
}
}
private static func threadJSON(_ thread: ProcessThread, detail: String) -> [String: Any] {
var obj: [String: Any] = ["id": Int(thread.id)]
if let name = thread.name {
obj["name"] = name
}
if detail == "full", let entrypoint = thread.entrypoint {
var ep: [String: Any] = ["routine": String(format: "0x%llx", entrypoint.routine)]
if let parameter = entrypoint.parameter {
ep["parameter"] = String(format: "0x%llx", parameter)
}
obj["entrypoint"] = ep
}
return obj
}
private static func registerListSessionInstruments(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "list_session_instruments",
description: "List all instruments currently attached to a session: tracer hooks, custom instrument instances, hookpacks, and codeshare snippets. Returns id, kind, source_identifier, state (enabled/disabled), attachment (attached/detached), status (present when the instance failed to load/reload/validate), and component_statuses (per-hook compile failures on tracer instances). Use to understand what's already running and whether anything is in error before adding more.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"}},"required":["session_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
let instances = engine.instrumentsBySession[sessionID] ?? []
let node = engine.node(forSessionID: sessionID)
let array: [[String: Any]] = instances.map { instance in
instanceListEntry(instance, ref: node?.instruments.first(where: { $0.id == instance.id }))
}
return makeResult(jsonObject: array, summary: "\(array.count) instrument\(array.count == 1 ? "" : "s") attached")
}
}
private static func instanceListEntry(_ instance: InstrumentInstance, ref: ProcessNode.InstrumentRef?) -> [String: Any] {
var entry: [String: Any] = [
"id": instance.id.uuidString,
"kind": instance.kind.rawValue,
"source_identifier": instance.sourceIdentifier,
"state": instance.state.rawValue,
"attachment": ref?.attachment.rawValue ?? "detached",
]
if let status = ref?.status {
entry["status"] = status.toWireJSON()
}
if let statuses = ref?.componentStatuses, !statuses.isEmpty {
entry["component_statuses"] = statuses.map { (id, status) -> [String: Any] in
var obj = status.toWireJSON()
obj["component_id"] = id.uuidString
return obj
}
}
return entry
}
private static func registerArmSession(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "arm_session",
description: "Arm spawn-gating on the session's device with the given match pattern (glob over identifiers / process names). Newly-spawned processes that match get held for inspection until the session resumes them. Requires user approval.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"match_pattern":{"type":"string","description":"Glob matched against new process identifiers / names"}},"required":["session_id","match_pattern"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let pattern = invocation.args["match_pattern"] as? String, !pattern.isEmpty else {
return errorResult("missing match_pattern", code: .invalidInput)
}
guard engine.sessions.contains(where: { $0.id == sessionID }) else {
return errorResult("no session with id \(sessionID)", code: .notFound)
}
await engine.armSession(id: sessionID, matchPattern: pattern)
let payload: [String: Any] = ["session_id": sessionID.uuidString, "match_pattern": pattern]
return makeResult(jsonObject: payload, summary: "Armed session with pattern \(pattern)")
}
}
private static func registerDisarmSession(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "disarm_session",
description: "Stop spawn-gating on the session's device.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"}},"required":["session_id"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard engine.sessions.contains(where: { $0.id == sessionID }) else {
return errorResult("no session with id \(sessionID)", code: .notFound)
}
await engine.disarmSession(id: sessionID)
let payload: [String: Any] = ["session_id": sessionID.uuidString]
return makeResult(jsonObject: payload, summary: "Disarmed session")
}
}
private static func registerResumeSession(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "resume_session",
description: "Resume a session held after a gated-spawn attach (phase = awaiting_initial_resume). Use after arm_session captures the target and you've installed any hooks; the target process actually starts running. No-op if the session is already running.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"}},"required":["session_id"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let node = engine.node(forSessionID: sessionID) else {
return errorResult("no attached session for id \(sessionID)", code: .notFound)
}
await engine.resumeSpawnedProcess(node: node)
let payload: [String: Any] = ["session_id": sessionID.uuidString]
return makeResult(jsonObject: payload, summary: "Resumed session")
}
}
private static func registerSummarizeRecentEvents(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "summarize_recent_events",
description: "Read the most recent runtime events from the global event log. Filter by session_id, kind (substring on the event kind name), or match_pattern (case-insensitive regex on the event summary). Pass since_event_id to only get events newer than a previously-seen id. Each event includes a compact structured 'payload' field using $type-tagged JS values (NativePointer / BigInt / Date / RegExp / Uint8Array / Function / Error / Map / Set / Truncated / ref). Tracer events are decoded into named fields with caller and backtrace pre-symbolicated as $type:Symbol references. Pass include_payload=false to omit payloads, or symbolicate=false to keep tracer addresses as raw NativePointer.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"kind":{"type":"string","description":"Filter by event kind, e.g. tracer, repl"},"match_pattern":{"type":"string","description":"Case-insensitive regex matched against the event summary."},"since_event_id":{"type":"string","description":"Only return events that arrived after this id."},"limit":{"type":"integer","minimum":1,"maximum":200,"description":"Max events to return (default 50)"},"include_payload":{"type":"boolean","description":"Include each event's structured payload (default true, capped to a compact depth)."},"symbolicate":{"type":"boolean","description":"For tracer events, symbolicate caller and backtrace addresses (default true)."}},"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
let limit = (invocation.args["limit"] as? Int) ?? 50
do {
let request = try parseEventListingRequest(invocation.args, defaultDetail: .compact)
let matched = filteredEvents(engine.eventLog.events, filter: request.filter)
let returned = Array(matched.suffix(limit))
return await eventListingResult(returned, totalConsidered: matched.count, options: request.options, engine: engine)
} catch let error as EventFilterError {
return errorResult(error.message, code: .invalidInput)
} catch {
return errorResult("filter failed: \(error.localizedDescription)")
}
}
}
private static func registerWaitForEvent(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "wait_for_event",
description: "Block until at least one matching runtime event arrives, or the timeout elapses. Same filter and payload shape as summarize_recent_events. Pair it with a hook install or a user-driven trigger so the agent doesn't need to poll. Returns matching events newer than since_event_id, or an empty list on timeout.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"kind":{"type":"string"},"match_pattern":{"type":"string"},"since_event_id":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":200,"description":"Max events to return (default 50)"},"timeout_ms":{"type":"integer","minimum":100,"maximum":60000,"description":"Maximum wait in milliseconds (default 30000, capped at 60000)"},"include_payload":{"type":"boolean"},"symbolicate":{"type":"boolean"}},"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
let limit = (invocation.args["limit"] as? Int) ?? 50
let timeoutMs = min((invocation.args["timeout_ms"] as? Int) ?? 30_000, 60_000)
let request: EventListingRequest
do {
request = try parseEventListingRequest(invocation.args, defaultDetail: .compact)
} catch let error as EventFilterError {
return errorResult(error.message, code: .invalidInput)
} catch {
return errorResult("filter failed: \(error.localizedDescription)")
}
let pollIntervalNs: UInt64 = 100_000_000
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0)
while true {
try? Task.checkCancellation()
let matched = filteredEvents(engine.eventLog.events, filter: request.filter)
if !matched.isEmpty {
let returned = Array(matched.suffix(limit))
return await eventListingResult(returned, totalConsidered: matched.count, options: request.options, engine: engine)
}
if Date() >= deadline {
return await eventListingResult([], totalConsidered: 0, options: request.options, engine: engine, summary: "No matching events within \(timeoutMs)ms")
}
try? await Task.sleep(nanoseconds: pollIntervalNs)
}
}
}
private static func registerReadEvent(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "read_event",
description: "Read a single runtime event by id, returning its full structured payload (uncapped depth). Tracer events are decoded into named fields with caller and backtrace pre-symbolicated as $type:Symbol references. The event log is a bounded ring buffer, so older ids may have been evicted; in that case the tool returns not-found.",
inputSchemaJSON: """
{"type":"object","properties":{"event_id":{"type":"string"},"symbolicate":{"type":"boolean","description":"For tracer events, symbolicate caller and backtrace addresses (default true)."}},"required":["event_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let raw = invocation.args["event_id"] as? String, let eventID = UUID(uuidString: raw) else {
return errorResult("missing or invalid event_id", code: .invalidInput)
}
guard let event = engine.eventLog.events.first(where: { $0.id == eventID }) else {
return errorResult("no event with id \(eventID); the ring buffer may have evicted it", code: .notFound)
}
let symbolicate = (invocation.args["symbolicate"] as? Bool) ?? true
let options = EventListingOptions(payloadDetail: .full, symbolicateAddresses: symbolicate)
let symbols = await collectSymbolLookup(in: [event], options: options, engine: engine)
let obj = renderEventJSON(event, options: options, symbols: symbols)
return makeResult(jsonObject: obj, summary: "Event \(eventID.uuidString) (\(describeEventKind(event)))")
}
}
private struct EventListingRequest {
let filter: EventFilter
let options: EventListingOptions
}
private struct EventListingOptions {
enum PayloadDetail {
case omit
case compact
case full
var jsonOptions: JSInspectValue.AgentJSONOptions? {
switch self {
case .omit: return nil
case .compact: return .compact
case .full: return .full
}
}
}
let payloadDetail: PayloadDetail
let symbolicateAddresses: Bool
}
private struct EventFilter {
var sessionID: UUID?
var kindSubstring: String?
var pattern: Regex<AnyRegexOutput>?
var sinceEventID: UUID?
}
private struct EventFilterError: Swift.Error {
let message: String
}
private static func parseEventListingRequest(_ args: [String: Any], defaultDetail: EventListingOptions.PayloadDetail) throws -> EventListingRequest {
let filter = try parseEventFilter(args)
let includePayload = (args["include_payload"] as? Bool) ?? true
let symbolicate = (args["symbolicate"] as? Bool) ?? true
let options = EventListingOptions(
payloadDetail: includePayload ? defaultDetail : .omit,
symbolicateAddresses: symbolicate
)
return EventListingRequest(filter: filter, options: options)
}
private static func parseEventFilter(_ args: [String: Any]) throws -> EventFilter {
var filter = EventFilter()
filter.sessionID = parseSessionID(args)
if let kind = args["kind"] as? String, !kind.isEmpty {
filter.kindSubstring = kind
}
if let raw = (args["match_pattern"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty {
do {
filter.pattern = try Regex(raw).ignoresCase()
} catch {
throw EventFilterError(message: "invalid match_pattern: \(error.localizedDescription)")
}
}
if let sinceRaw = args["since_event_id"] as? String, !sinceRaw.isEmpty {
guard let id = UUID(uuidString: sinceRaw) else {
throw EventFilterError(message: "invalid since_event_id")
}
filter.sinceEventID = id
}
return filter
}
private static func filteredEvents(_ events: [RuntimeEvent], filter: EventFilter) -> [RuntimeEvent] {
var slice = events[...]
if let since = filter.sinceEventID, let cut = slice.firstIndex(where: { $0.id == since }) {
slice = slice[slice.index(after: cut)...]
}
return slice.filter { event in
if let id = filter.sessionID, event.sessionID != id { return false }
if let kind = filter.kindSubstring, !describeEventKind(event).contains(kind) { return false }
if let pattern = filter.pattern, !describeEventSummary(event).contains(pattern) { return false }
return true
}
}
private static func eventListingResult(
_ events: [RuntimeEvent],
totalConsidered: Int,
options: EventListingOptions,
engine: Engine,
summary: String? = nil
) async -> ActionResult {
let symbols = await collectSymbolLookup(in: events, options: options, engine: engine)
let array = events.map { renderEventJSON($0, options: options, symbols: symbols) }
let resolvedSummary = summary ?? "Returned \(events.count) of \(totalConsidered) matching event\(totalConsidered == 1 ? "" : "s")"
return makeResult(jsonObject: array, summary: resolvedSummary)
}
private static func collectSymbolLookup(in events: [RuntimeEvent], options: EventListingOptions, engine: Engine) async -> SymbolLookup {
guard options.symbolicateAddresses else { return SymbolLookup() }
var addressesBySession: [UUID: Set<UInt64>] = [:]
for event in events {
guard let sid = event.sessionID, isTracerEvent(event) else { continue }
guard case .jsValue(let value) = event.payload,
let tracer = Engine.parseTracerEvent(from: value)
else { continue }
var addresses = addressesBySession[sid] ?? []
if let address = tracer.caller.nativePointerAddress {
addresses.insert(address)
}
for pointer in tracer.backtrace ?? [] {
if let address = pointer.nativePointerAddress {
addresses.insert(address)
}
}
addressesBySession[sid] = addresses
}
var bySession: [UUID: [UInt64: SymbolDisplay]] = [:]
for (sessionID, addressSet) in addressesBySession where !addressSet.isEmpty {
let addresses = Array(addressSet)
let displays = await engine.symbolDisplay(sessionID: sessionID, addresses: addresses)
var map: [UInt64: SymbolDisplay] = [:]
for (idx, display) in displays.enumerated() where idx < addresses.count {
map[addresses[idx]] = display
}
bySession[sessionID] = map
}
return SymbolLookup(bySession: bySession)
}
private struct SymbolLookup {
private let bySession: [UUID: [UInt64: SymbolDisplay]]
init(bySession: [UUID: [UInt64: SymbolDisplay]] = [:]) {
self.bySession = bySession
}
func symbol(for address: UInt64, sessionID: UUID) -> SymbolDisplay? {
bySession[sessionID]?[address]
}
}
private static func renderEventJSON(_ event: RuntimeEvent, options: EventListingOptions, symbols: SymbolLookup) -> [String: Any] {
let formatter = ISO8601DateFormatter()
var obj: [String: Any] = [
"id": event.id.uuidString,
"kind": describeEventKind(event),
"timestamp": formatter.string(from: event.timestamp),
"summary": describeEventSummary(event),
]
if let sid = event.sessionID { obj["session_id"] = sid.uuidString }
if let jsonOptions = options.payloadDetail.jsonOptions,
let payload = renderEventPayload(event, jsonOptions: jsonOptions, symbols: symbols) {
obj["payload"] = payload
}
return obj
}
private static func renderEventPayload(_ event: RuntimeEvent, jsonOptions: JSInspectValue.AgentJSONOptions, symbols: SymbolLookup) -> Any? {
switch event.payload {
case .consoleMessage(let msg):
return [
"$type": "ConsoleMessage",
"level": msg.level.rawValue,
"values": msg.values.map { $0.toAgentJSON(options: jsonOptions) },
] as [String: Any]
case .jsError(let err):
return jsErrorPayload(err, jsonOptions: jsonOptions)
case .jsValue(let value):
if isTracerEvent(event),
let decoded = decodeTracerPayload(value, sessionID: event.sessionID, jsonOptions: jsonOptions, symbols: symbols) {
return decoded
}
return value.toAgentJSON(options: jsonOptions)
case .raw:
return nil
}
}
private static func isTracerEvent(_ event: RuntimeEvent) -> Bool {
if case .instrument(_, let name) = event.source, name == "tracer" { return true }
return false
}
private static func decodeTracerPayload(
_ value: JSInspectValue,
sessionID: UUID?,
jsonOptions: JSInspectValue.AgentJSONOptions,
symbols: SymbolLookup
) -> [String: Any]? {
guard let tracer = Engine.parseTracerEvent(from: value) else { return nil }
var obj: [String: Any] = [
"$type": "TracerEvent",
"hook_id": tracer.id.uuidString,
"timestamp_ms": tracer.timestamp,
"thread_id": tracer.threadId,
"depth": tracer.depth,
"caller": symbolReferenceJSON(for: tracer.caller, sessionID: sessionID, symbols: symbols),
"message": tracer.message.toAgentJSON(options: jsonOptions),
]
if let backtrace = tracer.backtrace {
obj["backtrace"] = backtrace.map { symbolReferenceJSON(for: $0, sessionID: sessionID, symbols: symbols) }
}
return obj
}
private static func symbolReferenceJSON(for pointer: JSInspectValue, sessionID: UUID?, symbols: SymbolLookup) -> Any {
guard case .nativePointer(let raw) = pointer else {
return pointer.toAgentJSON(options: .compact)
}
guard let sid = sessionID,
let address = pointer.nativePointerAddress,
let display = symbols.symbol(for: address, sessionID: sid),
display.raw != nil || display.overlay != nil
else {
return ["$type": "NativePointer", "value": raw] as [String: Any]
}
return symbolJSON(display, address: raw)
}
private static func symbolJSON(_ display: SymbolDisplay, address: String) -> [String: Any] {
var obj: [String: Any] = [
"$type": "Symbol",
"address": address,
"name": display.primary,
]
if let raw = display.raw {
obj["module"] = raw.module
obj["raw_name"] = raw.name
if let offset = raw.offset {
obj["raw_offset"] = offset
}
}
if let overlay = display.overlay {
obj["insight_name"] = overlay.title
if overlay.offset != 0 {
obj["insight_offset"] = overlay.offset
}
}
if let source = display.source {
obj["file"] = source.file
obj["line"] = source.line
if let column = source.column {
obj["column"] = column
}
}
return obj
}
private static func jsErrorPayload(_ err: JSError, jsonOptions: JSInspectValue.AgentJSONOptions) -> [String: Any] {
var dict: [String: Any] = [
"$type": "Error",
"message": err.text,
]
if let fileName = err.fileName {
dict["file"] = fileName
}
if let lineNumber = err.lineNumber {
dict["line"] = lineNumber
}
if let columnNumber = err.columnNumber {
dict["column"] = columnNumber
}
if let stack = err.stack, !stack.isEmpty {
dict["stack"] = stack
}
return dict
}
private static func registerResolveSymbol(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "resolve_symbol",
description: "Resolve a symbol query to one or more addresses. Queries support globbing (e.g. '*Keychain*'). 'function' searches module exports; 'objc-method' / 'swift-func' / 'java-method' / 'debug-symbol' search those runtimes (Java requires the frida-java-bridge package — install via install_package). 'absolute-instruction' just packages a hex address as an instruction anchor; 'relative-function' takes 'MODULE!OFFSET'.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"scope":{"type":"string","enum":["function","module","imports","relative-function","absolute-instruction","objc-method","swift-func","java-method","debug-symbol"]},"query":{"type":"string"}},"required":["session_id","scope","query"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let node = engine.node(forSessionID: sessionID) else {
return errorResult("no attached session for id \(sessionID)", code: .notFound)
}
guard let scope = invocation.args["scope"] as? String,
let query = invocation.args["query"] as? String
else {
return errorResult("missing scope or query", code: .invalidInput)
}
do {
let results = try await node.resolveTargets(scope: scope, query: query)
return makeResult(jsonObject: results, summary: "Found \(results.count) match\(results.count == 1 ? "" : "es")")
} catch {
return errorResult("resolve failed: \(error.localizedDescription)")
}
}
}
private static func registerDisassemble(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "disassemble",
description: "Disassemble instructions starting at the given address. Returns plain-text assembly with addresses and bytes. Use after resolve_symbol to look at a function's body. The 'scope' field is 'function' when the address is a known function start (output covers the whole function, may be shorter than count) or 'span' otherwise (output is exactly count instructions from the address).",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"address":{"type":"string","description":"Hex address, e.g. 0x1004500"},"count":{"type":"integer","minimum":1,"maximum":256,"default":32}},"required":["session_id","address"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let addrString = invocation.args["address"] as? String,
let address = parseHexAddress(addrString)
else {
return errorResult("missing or invalid address", code: .invalidInput)
}
let count = (invocation.args["count"] as? Int) ?? 32
guard let dis = engine.disassembler(forSessionID: sessionID) else {
return errorResult("no disassembler for session", code: .notFound)
}
guard engine.node(forSessionID: sessionID) != nil else {
return errorResult("no attached session for id \(sessionID)", code: .notFound)
}
let page = await dis.disassemblePage(DisassemblyRequest(address: address, count: count, appearance: .light))
let text: String = page.lines.map { (line: DisassemblyLine) -> String in
let addr: String = String(format: "0x%llx", line.address)
let asm: String = line.asmText.plainText
let comment: String = line.commentText?.plainText ?? ""
let suffix: String = comment.isEmpty ? "" : " \(comment)"
return "\(addr) \(asm)\(suffix)"
}.joined(separator: "\n")
let scopeLabel: String
switch page.scope {
case .function: scopeLabel = "function"
case .span: scopeLabel = "span"
}
let payload: [String: Any] = [
"address": addrString,
"count": page.lines.count,
"scope": scopeLabel,
"text": text,
]
return makeResult(jsonObject: payload, summary: "Disassembled \(page.lines.count) instruction\(page.lines.count == 1 ? "" : "s") at \(addrString)")
}
}
private static func registerReadMemory(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "read_memory",
description: "Read up to 4096 bytes of process memory. 'format' picks the response shape: 'hex' (space-separated hex bytes, default), 'utf8' (decoded string up to the first invalid sequence), or 'cstring' (NUL-terminated C string, count is the maximum scan length). Use the narrowest format that answers your question — large hex dumps burn tokens.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"address":{"type":"string","description":"Hex address"},"count":{"type":"integer","minimum":1,"maximum":4096,"default":256},"format":{"type":"string","enum":["hex","utf8","cstring"],"default":"hex"}},"required":["session_id","address"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let addrString = invocation.args["address"] as? String,
let address = parseHexAddress(addrString)
else {
return errorResult("missing or invalid address", code: .invalidInput)
}
let count = min((invocation.args["count"] as? Int) ?? 256, 4096)
let format = (invocation.args["format"] as? String) ?? "hex"
guard ["hex", "utf8", "cstring"].contains(format) else {
return errorResult("format must be one of hex, utf8, cstring", code: .invalidInput)
}
guard let node = engine.node(forSessionID: sessionID) else {
return errorResult("no attached session for id \(sessionID)", code: .notFound)
}
do {
let bytes = try await node.readRemoteMemory(at: address, count: count)
var payload: [String: Any] = [
"address": addrString,
"count": bytes.count,
"format": format,
]
switch format {
case "hex":
payload["hex"] = bytes.map { String(format: "%02x", $0) }.joined(separator: " ")
case "utf8":
payload["string"] = String(bytes: bytes, encoding: .utf8) as Any? ?? NSNull()
case "cstring":
let nulIndex = bytes.firstIndex(of: 0) ?? bytes.endIndex
payload["string"] = String(bytes: bytes[..<nulIndex], encoding: .utf8) as Any? ?? NSNull()
default:
break
}
return makeResult(jsonObject: payload, summary: "Read \(bytes.count) bytes at \(addrString)")
} catch {
return errorResult("memory read failed: \(error.localizedDescription)", code: .failed)
}
}
}
private static func registerWriteMemory(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "write_memory",
description: "Write up to 4096 bytes to process memory. Pass 'bytes' as a hex string (e.g. 'deadbeef'). Returns the number of bytes written. Requires user approval; only use when you've justified the patch in a finding.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"address":{"type":"string","description":"Hex address"},"bytes":{"type":"string","description":"Hex string of bytes to write (no 0x prefix, no spaces, even length)"}},"required":["session_id","address","bytes"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let addrString = invocation.args["address"] as? String,
let address = parseHexAddress(addrString)
else {
return errorResult("missing or invalid address", code: .invalidInput)
}
guard let hexString = invocation.args["bytes"] as? String,
let bytes = parseHexBytes(hexString),
!bytes.isEmpty,
bytes.count <= 4096
else {
return errorResult("bytes must be a non-empty even-length hex string up to 8192 chars", code: .invalidInput)
}
guard let node = engine.node(forSessionID: sessionID) else {
return errorResult("no attached session for id \(sessionID)", code: .notFound)
}
do {
try await node.writeRemoteMemory(at: address, bytes: bytes)
let payload: [String: Any] = [
"address": addrString,
"count": bytes.count,
]
return makeResult(jsonObject: payload, summary: "Wrote \(bytes.count) bytes at \(addrString)")
} catch {
return errorResult("memory write failed: \(error.localizedDescription)")
}
}
}
private static func registerListNotebookEntries(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "list_notebook_entries",
description: "List notebook entries in this project. Returns id, kind (note or capture), title, a short details preview, and optional session/process attribution. Use read_notebook_entry to fetch full bodies.",
inputSchemaJSON: """
{"type":"object","properties":{},"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] _ in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
let array = engine.notebookEntries.map(notebookListEntry)
return makeResult(jsonObject: array, summary: "\(array.count) notebook entr\(array.count == 1 ? "y" : "ies")")
}
}
private static func registerReadNotebookEntry(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "read_notebook_entry",
description: "Read a notebook entry's full body, including the complete details text.",
inputSchemaJSON: """
{"type":"object","properties":{"entry_id":{"type":"string"}},"required":["entry_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let entryID = (invocation.args["entry_id"] as? String).flatMap(UUID.init(uuidString:)) else {
return errorResult("missing or invalid entry_id", code: .invalidInput)
}
guard let entry = engine.notebookEntries.first(where: { $0.id == entryID }) else {
return errorResult("no notebook entry with id \(entryID)", code: .notFound)
}
var payload = notebookListEntry(entry)
payload["details"] = entry.details
return makeResult(jsonObject: payload, summary: "Notebook entry: \(entry.title)")
}
}
private static func registerCreateNotebookEntry(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "create_notebook_entry",
description: "Create a notebook entry. 'kind' defaults to 'note' (a freeform note). Use 'capture' when the body comes from a target observation. Requires user approval.",
inputSchemaJSON: """
{"type":"object","properties":{"title":{"type":"string"},"details":{"type":"string"},"kind":{"type":"string","enum":["note","capture"],"default":"note"},"session_id":{"type":"string"}},"required":["title","details"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let title = invocation.args["title"] as? String, !title.isEmpty else {
return errorResult("missing title", code: .invalidInput)
}
guard let details = invocation.args["details"] as? String else {
return errorResult("missing details", code: .invalidInput)
}
let kind: NotebookEntry.Kind = ((invocation.args["kind"] as? String) == "capture") ? .capture : .note
let sessionID = (invocation.args["session_id"] as? String).flatMap(UUID.init(uuidString:))
let processName = sessionID.flatMap { id in engine.sessions.first(where: { $0.id == id })?.processName }
let entry = NotebookEntry(
kind: kind,
title: title,
details: details,
sessionID: sessionID,
processName: processName
)
engine.addNotebookEntry(entry)
return makeResult(jsonObject: ["entry_id": entry.id.uuidString, "title": entry.title], summary: "Created notebook entry: \(entry.title)")
}
}
private static func registerUpdateNotebookEntry(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "update_notebook_entry",
description: "Update a notebook entry's title, details, or kind. Only fields you pass change. Requires user approval.",
inputSchemaJSON: """
{"type":"object","properties":{"entry_id":{"type":"string"},"title":{"type":"string"},"details":{"type":"string"},"kind":{"type":"string","enum":["note","capture"]}},"required":["entry_id"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let entryID = (invocation.args["entry_id"] as? String).flatMap(UUID.init(uuidString:)) else {
return errorResult("missing or invalid entry_id", code: .invalidInput)
}
guard var entry = engine.notebookEntries.first(where: { $0.id == entryID }) else {
return errorResult("no notebook entry with id \(entryID)", code: .notFound)
}
if let title = invocation.args["title"] as? String, !title.isEmpty {
entry.title = title
}
if let details = invocation.args["details"] as? String {
entry.details = details
}
if let kindRaw = invocation.args["kind"] as? String {
entry.kind = (kindRaw == "capture") ? .capture : .note
}
engine.updateNotebookEntry(entry)
return makeResult(jsonObject: ["entry_id": entry.id.uuidString, "title": entry.title], summary: "Updated notebook entry: \(entry.title)")
}
}
private static func registerDeleteNotebookEntry(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "delete_notebook_entry",
description: "Delete a notebook entry. Requires user approval.",
inputSchemaJSON: """
{"type":"object","properties":{"entry_id":{"type":"string"}},"required":["entry_id"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let entryID = (invocation.args["entry_id"] as? String).flatMap(UUID.init(uuidString:)) else {
return errorResult("missing or invalid entry_id", code: .invalidInput)
}
guard let entry = engine.notebookEntries.first(where: { $0.id == entryID }) else {
return errorResult("no notebook entry with id \(entryID)", code: .notFound)
}
engine.deleteNotebookEntry(entry)
return makeResult(jsonObject: ["entry_id": entryID.uuidString, "removed": true], summary: "Deleted notebook entry: \(entry.title)")
}
}
private static func notebookListEntry(_ entry: NotebookEntry) -> [String: Any] {
var payload: [String: Any] = [
"entry_id": entry.id.uuidString,
"kind": entry.kind.rawValue,
"title": entry.title,
"preview": String(entry.details.prefix(200)),
"timestamp": ISO8601DateFormatter().string(from: entry.timestamp),
]
if let sessionID = entry.sessionID {
payload["session_id"] = sessionID.uuidString
}
if let processName = entry.processName {
payload["process_name"] = processName
}
return payload
}
private static func registerEvalREPL(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "eval_repl",
description: "Run a one-off JavaScript snippet in the target process via Frida's REPL and return its value inline. The result is `{cell_id, code, kind, value?, text?}` where kind is 'value' (JS expression result, `value` is the $type-tagged structured encoding used elsewhere) or 'text' (pipeline output or error message in `text`). Requires user approval — `intent` is shown to the approver and recorded in the audit log, so write a sentence that makes the why obvious.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"code":{"type":"string"},"intent":{"type":"string","description":"One sentence shown to the approver explaining why you're running this; logged with the action."}},"required":["session_id","code","intent"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true,
codePreview: CodePreviewArg(field: "code", language: .fridaJavaScript)
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let code = invocation.args["code"] as? String, !code.isEmpty else {
return errorResult("missing code", code: .invalidInput)
}
guard let node = engine.node(forSessionID: sessionID) else {
return errorResult("no attached session for id \(sessionID)", code: .notFound)
}
let cellID = UUID()
guard let result = await node.evalInREPL(code, cellID: cellID) else {
return errorResult("REPL evaluation produced no result", code: .unavailable)
}
return evalREPLResult(result)
}
}
private static func evalREPLResult(_ result: REPLResult) -> ActionResult {
let cellShort = result.id.uuidString.prefix(8)
var payload: [String: Any] = [
"cell_id": result.id.uuidString,
"code": result.code,
]
let summary: String
switch result.value {
case .js(let value):
payload["kind"] = "value"
payload["value"] = value.toAgentJSON(options: .compact)
summary = "REPL cell \(cellShort) → \(value.agentSummary())"
case .text(let text):
payload["kind"] = "text"
payload["text"] = text
let preview = text.count > 96 ? text.prefix(95) + "…" : Substring(text)
summary = "REPL cell \(cellShort) → \(preview)"
case .styled(let styled):
let text = styled.plainText
payload["kind"] = "text"
payload["text"] = text
let preview = text.count > 96 ? text.prefix(95) + "…" : Substring(text)
summary = "REPL cell \(cellShort) → \(preview)"
case .binary(let data, _):
payload["kind"] = "binary"
payload["byte_count"] = data.count
summary = "REPL cell \(cellShort) → \(data.count) bytes"
}
return makeResult(jsonObject: payload, summary: summary)
}
private static func registerInstallTracerHook(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "install_tracer_hook",
description: "Install a tracer hook. 'target' is either a hex address or a query for the chosen 'scope' (function / objc-method / swift-func / java-method / debug-symbol). 'kind' controls when the hook fires: 'function' on entry/exit, 'instruction' on a single instruction. Pass 'code' to start with custom JS in one shot; omit to install the default stub. The hook is enabled on install. If a hook already exists at the same anchor, the existing hook is returned unchanged — use update_tracer_hook to modify it. For Java, install_package frida-java-bridge first.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"target":{"type":"string","description":"Hex address (0x...) or symbol query for the chosen scope"},"scope":{"type":"string","enum":["function","objc-method","swift-func","java-method","debug-symbol"],"default":"function","description":"Resolver to use when target isn't a hex address. Ignored when target is hex."},"kind":{"type":"string","enum":["function","instruction"],"default":"function"},"code":{"type":"string","description":"Custom JS handler. Omit to use the default stub for this hook kind."}},"required":["session_id","target"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true,
codePreview: CodePreviewArg(field: "code", language: .fridaTypeScript)
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let target = invocation.args["target"] as? String, !target.isEmpty else {
return errorResult("missing target", code: .invalidInput)
}
let kindString = (invocation.args["kind"] as? String) ?? "function"
let kind: TracerHookKind = kindString == "instruction" ? .instruction : .function
let code = (invocation.args["code"] as? String).flatMap { $0.isEmpty ? nil : $0 }
let scope = (invocation.args["scope"] as? String) ?? "function"
let address: UInt64
var preferredAnchor: AddressAnchor?
var preferredDisplayName: String?
if let parsed = parseHexAddress(target) {
address = parsed
} else {
guard let node = engine.node(forSessionID: sessionID) else {
return errorResult("no attached session for id \(sessionID)", code: .notFound)
}
do {
let resolved = try await node.resolveTargets(scope: scope, query: target)
guard let first = resolved.first,
let addrStr = first["address"] as? String,
let parsed = parseHexAddress(addrStr)
else {
return errorResult("could not resolve target '\(target)' under scope '\(scope)'", code: .notFound)
}
address = parsed
preferredAnchor = decodeAnchorJSON(first["anchor"] as? [String: Any])
preferredDisplayName = first["displayName"] as? String
} catch {
return errorResult("resolve failed: \(error.localizedDescription)")
}
}
guard let result = await engine.addTracerHook(
sessionID: sessionID,
address: address,
kind: kind,
code: code,
preferredAnchor: preferredAnchor,
preferredDisplayName: preferredDisplayName
) else {
return errorResult("failed to install hook at \(String(format: "0x%llx", address))")
}
let payload: [String: Any] = [
"instrument_id": result.instrumentID.uuidString,
"hook_id": result.hookID.uuidString,
"address": String(format: "0x%llx", address),
"target": target,
]
return makeResult(
jsonObject: payload,
summary: "Installed tracer hook at \(String(format: "0x%llx", address)) (\(target))"
)
}
}
private static func decodeAnchorJSON(_ obj: [String: Any]?) -> AddressAnchor? {
guard let obj, let type = obj["type"] as? String else { return nil }
switch type {
case "absolute":
guard let addrStr = obj["address"] as? String, let addr = parseHexAddress(addrStr) else { return nil }
return .absolute(addr)
case "moduleOffset":
guard let name = obj["name"] as? String, let offset = obj["offset"] as? Int else { return nil }
return .moduleOffset(name: name, offset: UInt64(offset))
case "moduleExport":
guard let name = obj["name"] as? String, let export = obj["export"] as? String else { return nil }
return .moduleExport(name: name, export: export)
case "objcMethod":
guard let selector = obj["selector"] as? String else { return nil }
return .objcMethod(selector: selector)
case "swiftFunc":
guard let module = obj["module"] as? String, let function = obj["function"] as? String else { return nil }
return .swiftFunc(module: module, function: function)
case "javaMethod":
guard let className = obj["className"] as? String, let methodName = obj["methodName"] as? String else { return nil }
return .javaMethod(className: className, methodName: methodName)
case "debugSymbol":
guard let name = obj["name"] as? String else { return nil }
return .debugSymbol(name: name)
default:
return nil
}
}
private static func registerListTracerHooks(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "list_tracer_hooks",
description: "List tracer hooks installed on the session. Returns metadata only (id, target, kind, state, itrace) plus `compile_error` on any hook whose JS failed to compile. Fetch the JS body via read_tracer_hook when you need it.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"}},"required":["session_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let hooks = engine.tracerHooks(forSessionID: sessionID) else {
return makeResult(jsonObject: [], summary: "No tracer instrument on this session")
}
let array: [[String: Any]] = hooks.map { hook in
var entry = hookListEntry(hook)
if let err = tracerHookComponentStatus(engine: engine, sessionID: sessionID, hookID: hook.id) {
entry["compile_error"] = err
}
return entry
}
return makeResult(jsonObject: array, summary: "\(hooks.count) tracer hook\(hooks.count == 1 ? "" : "s")")
}
}
private static func registerReadTracerHook(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "read_tracer_hook",
description: "Read a tracer hook's full body, including its JS handler code. The `code` field is line-numbered (1-based, `<lineno>\\t<text>\\n`); strip the prefix before reuse. `line_count` is also returned. Use this only when you intend to read or edit the code; list_tracer_hooks is cheaper for surveying hooks.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"hook_id":{"type":"string"}},"required":["session_id","hook_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let hookID = (invocation.args["hook_id"] as? String).flatMap(UUID.init(uuidString:)) else {
return errorResult("missing or invalid hook_id", code: .invalidInput)
}
guard let hook = engine.tracerHook(sessionID: sessionID, hookID: hookID) else {
return errorResult("no tracer hook with id \(hookID)", code: .notFound)
}
var payload = hookListEntry(hook)
payload["code"] = numberedContent(hook.code)
payload["line_count"] = lineCount(of: hook.code)
if let err = tracerHookComponentStatus(engine: engine, sessionID: sessionID, hookID: hookID) {
payload["compile_error"] = err
}
return makeResult(jsonObject: payload, summary: "Hook \(hook.displayName)")
}
}
private static func registerUpdateTracerHook(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "update_tracer_hook",
description: "Update one or more fields of a tracer hook. Only fields you pass change. Pass 'code' to swap the JS handler. Pass 'state' (\"enabled\" or \"disabled\") to toggle the hook. Pass 'itrace_arming' to arm instruction tracing for this hook with safety caps (max_invocations stops new captures once it's reached; max_bytes_per_invocation auto-stops a single capture once it crosses that many bytes). Pass null to disarm.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"hook_id":{"type":"string"},"code":{"type":"string"},"display_name":{"type":"string"},"state":{"type":"string","enum":["enabled","disabled"]},"itrace_arming":{"type":["object","null"],"properties":{"max_invocations":{"type":"integer","minimum":1,"default":5},"max_bytes_per_invocation":{"type":"integer","minimum":1024,"default":1000000}},"additionalProperties":false}},"required":["session_id","hook_id"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true,
codePreview: CodePreviewArg(field: "code", language: .fridaTypeScript)
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let hookID = (invocation.args["hook_id"] as? String).flatMap(UUID.init(uuidString:)) else {
return errorResult("missing or invalid hook_id", code: .invalidInput)
}
let code = invocation.args["code"] as? String
let displayName = invocation.args["display_name"] as? String
let state = (invocation.args["state"] as? String).flatMap(TracerConfig.Hook.State.init(rawValue:))
let armingArg = invocation.args["itrace_arming"]
guard let updated = await engine.updateTracerHook(sessionID: sessionID, hookID: hookID, { hook in
if let code { hook.updateCode(code) }
if let displayName { hook.displayName = displayName }
if let state { hook.state = state }
if armingArg is NSNull {
hook.itraceArming = nil
} else if let armingObj = armingArg as? [String: Any] {
let maxInvocations = (armingObj["max_invocations"] as? Int) ?? ITraceArming.defaultMaxInvocations
let maxBytes = (armingObj["max_bytes_per_invocation"] as? Int) ?? ITraceArming.defaultMaxBytesPerInvocation
hook.itraceArming = ITraceArming(maxInvocations: maxInvocations, maxBytesPerInvocation: maxBytes)
}
}) else {
return errorResult("no tracer hook with id \(hookID)", code: .notFound)
}
return tracerHookEditResult(engine: engine, sessionID: sessionID, hook: updated, summaryPrefix: "Updated hook \(updated.displayName)")
}
}
private static func registerEditTracerHook(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "edit_tracer_hook",
description: """
Replace a contiguous range of lines in a tracer hook's JS handler. Lines are 1-based and inclusive; \
`start_line` is the first line replaced and `end_line` is the last. To insert without replacing, pass \
`end_line = start_line - 1`; to append, use `start_line = line_count + 1, end_line = line_count`. \
`new_content` may span multiple lines and does not need a trailing newline. Prefer this over \
update_tracer_hook's `code` field for targeted edits.
""",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"hook_id":{"type":"string"},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":0},"new_content":{"type":"string"}},"required":["session_id","hook_id","start_line","end_line","new_content"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true,
codePreview: CodePreviewArg(field: "new_content", language: .fridaTypeScript)
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let hookID = (invocation.args["hook_id"] as? String).flatMap(UUID.init(uuidString:)) else {
return errorResult("missing or invalid hook_id", code: .invalidInput)
}
guard let startLine = invocation.args["start_line"] as? Int else {
return errorResult("missing or invalid start_line", code: .invalidInput)
}
guard let endLine = invocation.args["end_line"] as? Int else {
return errorResult("missing or invalid end_line", code: .invalidInput)
}
guard let newContent = invocation.args["new_content"] as? String else {
return errorResult("missing new_content", code: .invalidInput)
}
guard let existing = engine.tracerHook(sessionID: sessionID, hookID: hookID) else {
return errorResult("no tracer hook with id \(hookID)", code: .notFound)
}
let updatedCode: String
switch spliceLines(in: existing.code, startLine: startLine, endLine: endLine, replacement: newContent) {
case .success(let result):
updatedCode = result
case .failure(let message):
return errorResult(message, code: .invalidInput)
}
guard let updated = await engine.updateTracerHook(sessionID: sessionID, hookID: hookID, { hook in
hook.updateCode(updatedCode)
}) else {
return errorResult("no tracer hook with id \(hookID)", code: .notFound)
}
return tracerHookEditResult(
engine: engine,
sessionID: sessionID,
hook: updated,
summaryPrefix: "Edited hook \(updated.displayName) lines \(startLine)–\(endLine)",
extraFields: ["line_count": lineCount(of: updatedCode)]
)
}
}
private static func tracerHookEditResult(
engine: Engine,
sessionID: UUID,
hook: TracerConfig.Hook,
summaryPrefix: String,
extraFields: [String: Any] = [:]
) -> ActionResult {
var payload = hookListEntry(hook)
for (k, v) in extraFields { payload[k] = v }
let apply = tracerHookApplyJSON(engine: engine, sessionID: sessionID, hookID: hook.id)
payload["apply"] = apply
return makeResult(jsonObject: payload, summary: summaryPrefix + applySummarySuffix(apply))
}
private static func hookListEntry(_ hook: TracerConfig.Hook) -> [String: Any] {
var entry: [String: Any] = [
"hook_id": hook.id.uuidString,
"display_name": hook.displayName,
"kind": hook.kind.rawValue,
"state": hook.state.rawValue,
]
if let arming = hook.itraceArming {
entry["itrace_arming"] = [
"max_invocations": arming.maxInvocations,
"max_bytes_per_invocation": arming.maxBytesPerInvocation,
]
}
return entry
}
private static func tracerHookComponentStatus(engine: Engine, sessionID: UUID, hookID: UUID) -> [String: Any]? {
guard let node = engine.node(forSessionID: sessionID),
let ref = node.instruments.first(where: { $0.kind == .tracer }),
let status = ref.componentStatuses[hookID]
else { return nil }
return status.toWireJSON()
}
private static func tracerHookApplyJSON(engine: Engine, sessionID: UUID, hookID: UUID) -> [String: Any] {
guard let node = engine.node(forSessionID: sessionID),
let ref = node.instruments.first(where: { $0.kind == .tracer }),
ref.attachment == .attached
else {
return ["applied": false, "reason": "session not attached — edit persisted; will compile on next attach"]
}
if let status = ref.componentStatuses[hookID] {
return ["applied": false, "compile_error": status.toWireJSON()]
}
if let status = ref.status {
return ["applied": false, "instrument_error": status.toWireJSON()]
}
return ["applied": true]
}
private static func applySummarySuffix(_ apply: [String: Any]) -> String {
if apply["applied"] as? Bool == true { return "" }
if let err = apply["compile_error"] as? [String: Any], let msg = err["message"] as? String {
return " — compile error: \(msg.prefix(80))"
}
if let err = apply["instrument_error"] as? [String: Any], let msg = err["message"] as? String {
return " — instrument error: \(msg.prefix(80))"
}
if let reason = apply["reason"] as? String {
return " — \(reason)"
}
return ""
}
private static func registerRemoveTracerHook(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "remove_tracer_hook",
description: "Remove a tracer hook from the session.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"hook_id":{"type":"string"}},"required":["session_id","hook_id"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let hookID = (invocation.args["hook_id"] as? String).flatMap(UUID.init(uuidString:)) else {
return errorResult("missing or invalid hook_id", code: .invalidInput)
}
let removed = await engine.removeTracerHook(sessionID: sessionID, hookID: hookID)
guard removed else {
return errorResult("no tracer hook with id \(hookID)", code: .notFound)
}
let payload: [String: Any] = ["hook_id": hookID.uuidString, "removed": true]
return makeResult(jsonObject: payload, summary: "Removed hook \(hookID)")
}
}
private static func registerListInstruments(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "list_instruments",
description: "List instrument definitions known to this project — both editable custom instruments and shipped hookpacks. Each entry has `id` (opaque descriptor id; pass to attach_instrument), `kind` (custom | hookpack), `name`, `icon`. Custom instruments also report `def_id` (UUID for the custom_instrument_file/def tools) and source counts.",
inputSchemaJSON: """
{"type":"object","properties":{},"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] _ in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
let array: [[String: Any]] = engine.descriptors.compactMap { instrumentDescriptorListEntry($0, engine: engine) }
return makeResult(jsonObject: array, summary: "\(array.count) instrument\(array.count == 1 ? "" : "s")")
}
}
private static func instrumentDescriptorListEntry(_ descriptor: InstrumentDescriptor, engine: Engine) -> [String: Any]? {
var entry: [String: Any] = [
"id": descriptor.id,
"name": descriptor.displayName,
"icon": describeIcon(descriptor.icon),
]
switch descriptor.kind {
case .tracer:
entry["kind"] = "tracer"
case .custom:
guard let defID = UUID(uuidString: descriptor.sourceIdentifier),
let def = engine.customInstruments.def(withId: defID)
else { return nil }
entry["kind"] = "custom"
entry["def_id"] = defID.uuidString
entry["feature_count"] = def.features.count
entry["widget_count"] = def.widgets.count
case .hookPack:
entry["kind"] = "hookpack"
case .codeShare:
return nil
}
return entry
}
private static func registerReadCustomInstrument(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "read_custom_instrument",
description: "Read a custom instrument's metadata: entrypoint, features, widgets, and the list of file paths. File contents are NOT included — call read_custom_instrument_file for each path you need.",
inputSchemaJSON: """
{"type":"object","properties":{"def_id":{"type":"string"}},"required":["def_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
await withResolvedCustomInstrumentDef(invocation.args, engine: engine) { engine, defID, def in
let files = engine.customInstruments.files(forDefID: defID)
return makeResult(jsonObject: customInstrumentJSON(def: def, files: files), summary: "Custom instrument \(def.name)")
}
}
}
private static func registerCreateCustomInstrument(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "create_custom_instrument",
description: "Create a custom instrument definition seeded with a `main.ts` file containing the canonical skeleton (the entrypoint). Use write_custom_instrument_file to replace its contents, and add helper files as needed. Optional 'icon' is a catalog id (e.g. wand-stars, bug, scope, network). Optional 'features' declares config toggles; 'widgets' declares live UI elements.",
inputSchemaJSON: """
{"type":"object","properties":{"name":{"type":"string"},"icon":{"type":"string","description":"Catalog id like wand-stars, bug, scope, network — see list_custom_instrument_icons"},"compatibility":\(compatibilitySchemaJSON),"features":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled_by_default":{"type":"boolean","default":true}},"required":["id","name"],"additionalProperties":false}},"widgets":\(widgetsSchemaJSON)},"required":["name"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let name = invocation.args["name"] as? String, !name.isEmpty else {
return errorResult("missing name", code: .invalidInput)
}
let icon = parseIconArg(invocation.args["icon"] as? String)
let compatibility = parseCompatibilityArg(invocation.args["compatibility"])
let features = parseFeaturesArg(invocation.args["features"])
let widgets = parseWidgetsArg(invocation.args["widgets"])
var def = engine.createCustomInstrument(name: name, icon: icon)
if !compatibility.isUniversal || !features.isEmpty || !widgets.isEmpty {
def.compatibility = compatibility
def.features = features
def.widgets = widgets
engine.updateCustomInstrument(def)
}
return makeResult(
jsonObject: ["def_id": def.id.uuidString, "name": def.name, "entrypoint": def.entrypoint],
summary: "Created custom instrument \(def.name)"
)
}
}
private static func registerUpdateCustomInstrument(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "update_custom_instrument",
description: "Update a custom instrument's metadata: name, icon, compatibility, features, or widgets. Only fields you pass change. Passing 'features' or 'widgets' replaces the entire list — pass an empty array to clear. File contents are managed via write_custom_instrument_file; the entrypoint via set_custom_instrument_entrypoint.",
inputSchemaJSON: """
{"type":"object","properties":{"def_id":{"type":"string"},"name":{"type":"string"},"icon":{"type":"string"},"compatibility":\(compatibilitySchemaJSON),"features":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled_by_default":{"type":"boolean","default":true}},"required":["id","name"],"additionalProperties":false}},"widgets":\(widgetsSchemaJSON)},"required":["def_id"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
await withResolvedCustomInstrumentDef(invocation.args, engine: engine) { engine, _, defConst in
var def = defConst
if let name = invocation.args["name"] as? String, !name.isEmpty {
def.name = name
}
if let iconID = invocation.args["icon"] as? String {
def.icon = parseIconArg(iconID)
}
if invocation.args["compatibility"] != nil {
def.compatibility = parseCompatibilityArg(invocation.args["compatibility"])
}
if invocation.args["features"] != nil {
def.features = parseFeaturesArg(invocation.args["features"])
}
if invocation.args["widgets"] != nil {
def.widgets = parseWidgetsArg(invocation.args["widgets"])
}
engine.updateCustomInstrument(def)
await engine.awaitCustomInstrumentReload(defID: def.id)
return customInstrumentEditResult(
engine: engine,
defID: def.id,
summaryPrefix: "Updated custom instrument \(def.name)",
extraFields: ["name": def.name]
)
}
}
}
private static func registerListCustomInstrumentFiles(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "list_custom_instrument_files",
description: "List the file paths inside a custom instrument's source tree. Contents are not included; call read_custom_instrument_file for each path you need.",
inputSchemaJSON: """
{"type":"object","properties":{"def_id":{"type":"string"}},"required":["def_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
await withResolvedCustomInstrumentDef(invocation.args, engine: engine) { engine, defID, def in
let paths = engine.customInstruments.files(forDefID: defID).map(\.path).sorted()
return makeResult(
jsonObject: ["def_id": defID.uuidString, "entrypoint": def.entrypoint, "paths": paths],
summary: "\(paths.count) file\(paths.count == 1 ? "" : "s") in \(def.name)"
)
}
}
}
private static func registerReadCustomInstrumentFile(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "read_custom_instrument_file",
description: "Read one file's TypeScript source from a custom instrument. The `content` field is line-numbered (1-based, `<lineno>\\t<text>\\n`) so you can target edit_custom_instrument_file precisely; strip the prefix before reusing the text elsewhere. `line_count` is also returned.",
inputSchemaJSON: """
{"type":"object","properties":{"def_id":{"type":"string"},"path":{"type":"string"}},"required":["def_id","path"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
await withResolvedCustomInstrumentFile(invocation.args, engine: engine) { _, defID, _, file in
makeResult(
jsonObject: [
"def_id": defID.uuidString,
"path": file.path,
"content": numberedContent(file.content),
"line_count": lineCount(of: file.content),
],
summary: "Read \(file.path)"
)
}
}
}
private static func registerWriteCustomInstrumentFile(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "write_custom_instrument_file",
description: "Create or replace the contents of one file in a custom instrument. Path is relative inside the instrument's source tree (subdirectories allowed). Use this to split an instrument across multiple files by concept (e.g. symbols.ts, decoder.ts); imports between them must use a `.js` extension even though the on-disk file is `.ts` (e.g. `import { resolveSymbol } from \"./symbols.js\"`) — standard NodeNext/ESM convention enforced by the bundler. Running instances are recompiled.",
inputSchemaJSON: """
{"type":"object","properties":{"def_id":{"type":"string"},"path":{"type":"string"},"content":{"type":"string"}},"required":["def_id","path","content"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false,
codePreview: CodePreviewArg(field: "content", language: .fridaTypeScript)
)
catalog.register(spec: spec) { [weak engine] invocation in
await withResolvedCustomInstrumentDef(invocation.args, engine: engine) { engine, defID, _ in
guard let path = invocation.args["path"] as? String, !path.isEmpty else {
return errorResult("missing path", code: .invalidInput)
}
guard let content = invocation.args["content"] as? String else {
return errorResult("missing content", code: .invalidInput)
}
do {
let writtenPath = try engine.writeCustomInstrumentFile(defID: defID, path: path, content: content)
await engine.awaitCustomInstrumentReload(defID: defID)
return customInstrumentEditResult(
engine: engine,
defID: defID,
summaryPrefix: "Wrote \(writtenPath)",
extraFields: ["path": writtenPath]
)
} catch {
return errorResult(error.localizedDescription, code: .invalidInput)
}
}
}
}
private static func registerEditCustomInstrumentFile(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "edit_custom_instrument_file",
description: """
Replace a contiguous range of lines in one file of a custom instrument. Lines are 1-based and inclusive; \
`start_line` is the first line replaced and `end_line` is the last. To insert without replacing, pass \
`end_line = start_line - 1` (inserts before `start_line`); to append, use `start_line = line_count + 1, \
end_line = line_count`. `new_content` may span multiple lines and does not need a trailing newline. Prefer \
this over write_custom_instrument_file for targeted edits to large files. Running instances are recompiled.
""",
inputSchemaJSON: """
{"type":"object","properties":{"def_id":{"type":"string"},"path":{"type":"string"},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":0},"new_content":{"type":"string"}},"required":["def_id","path","start_line","end_line","new_content"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false,
codePreview: CodePreviewArg(field: "new_content", language: .fridaTypeScript)
)
catalog.register(spec: spec) { [weak engine] invocation in
await withResolvedCustomInstrumentFile(invocation.args, engine: engine) { engine, defID, _, file in
guard let startLine = invocation.args["start_line"] as? Int else {
return errorResult("missing or invalid start_line", code: .invalidInput)
}
guard let endLine = invocation.args["end_line"] as? Int else {
return errorResult("missing or invalid end_line", code: .invalidInput)
}
guard let newContent = invocation.args["new_content"] as? String else {
return errorResult("missing new_content", code: .invalidInput)
}
let updated: String
switch spliceLines(in: file.content, startLine: startLine, endLine: endLine, replacement: newContent) {
case .success(let result):
updated = result
case .failure(let message):
return errorResult(message, code: .invalidInput)
}
do {
let writtenPath = try engine.writeCustomInstrumentFile(defID: defID, path: file.path, content: updated)
await engine.awaitCustomInstrumentReload(defID: defID)
return customInstrumentEditResult(
engine: engine,
defID: defID,
summaryPrefix: "Edited \(writtenPath) lines \(startLine)–\(endLine)",
extraFields: ["path": writtenPath, "line_count": lineCount(of: updated)]
)
} catch {
return errorResult(error.localizedDescription, code: .invalidInput)
}
}
}
}
private static func registerDeleteCustomInstrumentFile(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "delete_custom_instrument_file",
description: "Delete one file from a custom instrument. Refuses to delete the entrypoint — call set_custom_instrument_entrypoint first to point at a different file.",
inputSchemaJSON: """
{"type":"object","properties":{"def_id":{"type":"string"},"path":{"type":"string"}},"required":["def_id","path"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
await withResolvedCustomInstrumentFile(invocation.args, engine: engine) { engine, defID, def, file in
if def.entrypoint == file.path {
return errorResult("cannot delete entrypoint '\(file.path)' — re-point with set_custom_instrument_entrypoint first", code: .invalidInput)
}
do {
try engine.deleteCustomInstrumentFile(defID: defID, path: file.path)
await engine.awaitCustomInstrumentReload(defID: defID)
return customInstrumentEditResult(
engine: engine,
defID: defID,
summaryPrefix: "Deleted \(file.path)",
extraFields: ["path": file.path]
)
} catch {
return errorResult(error.localizedDescription, code: .invalidInput)
}
}
}
}
private static func registerRenameCustomInstrumentFile(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "rename_custom_instrument_file",
description: "Rename a file inside a custom instrument. If the renamed file was the entrypoint, the entrypoint is updated automatically.",
inputSchemaJSON: """
{"type":"object","properties":{"def_id":{"type":"string"},"from":{"type":"string"},"to":{"type":"string"}},"required":["def_id","from","to"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
await withResolvedCustomInstrumentDef(invocation.args, engine: engine) { engine, defID, _ in
guard let from = invocation.args["from"] as? String, !from.isEmpty,
let to = invocation.args["to"] as? String, !to.isEmpty
else {
return errorResult("missing from/to", code: .invalidInput)
}
guard engine.customInstruments.file(defID: defID, path: from) != nil else {
return errorResult("no file '\(from)'", code: .notFound)
}
do {
let renamedPath = try engine.renameCustomInstrumentFile(defID: defID, from: from, to: to)
await engine.awaitCustomInstrumentReload(defID: defID)
return customInstrumentEditResult(
engine: engine,
defID: defID,
summaryPrefix: "Renamed \(from) → \(renamedPath)",
extraFields: ["from": from, "to": renamedPath]
)
} catch {
return errorResult(error.localizedDescription, code: .invalidInput)
}
}
}
}
private static func registerSetCustomInstrumentEntrypoint(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "set_custom_instrument_entrypoint",
description: "Mark which file is the entrypoint the agent loads first. The path must exist among the instrument's files.",
inputSchemaJSON: """
{"type":"object","properties":{"def_id":{"type":"string"},"path":{"type":"string"}},"required":["def_id","path"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
await withResolvedCustomInstrumentFile(invocation.args, engine: engine) { engine, defID, _, file in
do {
let entrypoint = try engine.setCustomInstrumentEntrypoint(defID: defID, path: file.path)
await engine.awaitCustomInstrumentReload(defID: defID)
return customInstrumentEditResult(
engine: engine,
defID: defID,
summaryPrefix: "Entrypoint set to \(entrypoint)",
extraFields: ["entrypoint": entrypoint]
)
} catch {
return errorResult(error.localizedDescription, code: .invalidInput)
}
}
}
}
private static func customInstrumentEditResult(
engine: Engine,
defID: UUID,
summaryPrefix: String,
extraFields: [String: Any] = [:]
) -> ActionResult {
var payload: [String: Any] = ["def_id": defID.uuidString]
for (k, v) in extraFields { payload[k] = v }
let apply = customInstrumentApplyJSON(engine: engine, defID: defID)
payload["apply"] = apply
return makeResult(jsonObject: payload, summary: summaryPrefix + customInstrumentSummarySuffix(apply))
}
private static func customInstrumentApplyJSON(engine: Engine, defID: UUID) -> [String: Any] {
let key = defID.uuidString
var appliedSessions: [[String: Any]] = []
var errors: [[String: Any]] = []
var detachedSessions: [String] = []
for (sessionID, instances) in engine.instrumentsBySession {
for inst in instances where inst.kind == .custom && inst.sourceIdentifier == key {
guard let node = engine.node(forSessionID: sessionID),
let ref = node.instruments.first(where: { $0.id == inst.id })
else {
detachedSessions.append(sessionID.uuidString)
continue
}
if let status = ref.status {
errors.append([
"session_id": sessionID.uuidString,
"instance_id": inst.id.uuidString,
"error": status.toWireJSON(),
])
} else if ref.attachment == .attached {
appliedSessions.append([
"session_id": sessionID.uuidString,
"instance_id": inst.id.uuidString,
])
} else {
detachedSessions.append(sessionID.uuidString)
}
}
}
var out: [String: Any] = ["applied_sessions": appliedSessions]
if !errors.isEmpty { out["errors"] = errors }
if !detachedSessions.isEmpty { out["detached_sessions"] = detachedSessions }
return out
}
private static func customInstrumentSummarySuffix(_ apply: [String: Any]) -> String {
if let errors = apply["errors"] as? [[String: Any]], let first = errors.first,
let status = first["error"] as? [String: Any], let msg = status["message"] as? String
{
let n = errors.count
return " — error on \(n) session\(n == 1 ? "" : "s"): \(msg.prefix(80))"
}
let appliedCount = (apply["applied_sessions"] as? [[String: Any]])?.count ?? 0
if appliedCount > 0 {
return " — applied on \(appliedCount) session\(appliedCount == 1 ? "" : "s")"
}
return ""
}
private static func registerDeleteCustomInstrument(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "delete_custom_instrument",
description: "Delete a custom instrument definition. Any sessions where it's currently attached have the instance removed automatically.",
inputSchemaJSON: """
{"type":"object","properties":{"def_id":{"type":"string"}},"required":["def_id"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let defID = (invocation.args["def_id"] as? String).flatMap(UUID.init(uuidString:)) else {
return errorResult("missing or invalid def_id", code: .invalidInput)
}
guard engine.customInstruments.def(withId: defID) != nil else {
return errorResult("no custom instrument with id \(defID)", code: .notFound)
}
await engine.deleteCustomInstrument(defID)
return makeResult(jsonObject: ["def_id": defID.uuidString, "removed": true], summary: "Deleted custom instrument \(defID)")
}
}
private static func registerAttachInstrument(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "attach_instrument",
description: "Attach an instrument (tracer, custom instrument, or hookpack) to a session. Pass the `id` returned by list_instruments. An instrument can only be attached once per session; calling again returns the existing instance with `already_attached: true`.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"id":{"type":"string","description":"Descriptor id from list_instruments"}},"required":["session_id","id"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let descriptorID = invocation.args["id"] as? String, !descriptorID.isEmpty else {
return errorResult("missing id", code: .invalidInput)
}
guard let descriptor = engine.descriptor(withID: descriptorID) else {
return errorResult("no instrument with id '\(descriptorID)'", code: .notFound)
}
guard descriptor.kind != .codeShare else {
return errorResult("codeshare snippets are attached via their own flow, not via this tool", code: .invalidInput)
}
if let existing = engine.instrumentsBySession[sessionID]?.first(where: {
$0.kind == descriptor.kind && $0.sourceIdentifier == descriptor.sourceIdentifier
}) {
let payload: [String: Any] = [
"instrument_id": existing.id.uuidString,
"id": descriptor.id,
"kind": descriptor.kind.rawValue,
"session_id": sessionID.uuidString,
"already_attached": true,
]
return makeResult(jsonObject: payload, summary: "\(descriptor.displayName) already attached to session")
}
guard let instance = await engine.addInstrument(
kind: descriptor.kind,
sourceIdentifier: descriptor.sourceIdentifier,
configJSON: descriptor.makeInitialConfigJSON(),
sessionID: sessionID
) else {
return errorResult("could not attach \(descriptor.displayName) to session", code: .unavailable)
}
let payload: [String: Any] = [
"instrument_id": instance.id.uuidString,
"id": descriptor.id,
"kind": descriptor.kind.rawValue,
"session_id": sessionID.uuidString,
]
return makeResult(jsonObject: payload, summary: "Attached \(descriptor.displayName) to session")
}
}
private static func registerReadTracerHandlerTemplate(in catalog: ToolCatalog) {
let spec = ActionSpec(
name: "read_tracer_handler_template",
description: "Return the canonical defineHandler() skeleton for a tracer hook. 'kind' is one of: instruction, native, objc, swift, java. Use this when authoring code for install_tracer_hook or update_tracer_hook.",
inputSchemaJSON: """
{"type":"object","properties":{"kind":{"type":"string","enum":["instruction","native","objc","swift","java"]}},"required":["kind"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { invocation in
guard let kind = invocation.args["kind"] as? String else {
return errorResult("missing kind", code: .invalidInput)
}
guard let template = tracerHandlerTemplate(kind: kind) else {
return errorResult("unknown kind '\(kind)'", code: .invalidInput)
}
return makeResult(jsonObject: ["kind": kind, "template": template], summary: "\(kind) tracer handler template")
}
}
private static func registerReadCustomInstrumentTemplate(in catalog: ToolCatalog) {
let spec = ActionSpec(
name: "read_custom_instrument_template",
description: "Return the canonical TypeScript skeleton for a custom instrument: how create(ctx, config) is called, how to emit events via ctx.emit, how features are typed, and how dispose() should undo every side effect.",
inputSchemaJSON: """
{"type":"object","properties":{},"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { _ in
return makeResult(
jsonObject: ["template": CustomInstrumentDef.defaultEntrypointSource],
summary: "Custom instrument source template"
)
}
}
private static func registerReadCustomInstrumentTypings(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "read_custom_instrument_typings",
description: "Return the TypeScript ambient declarations the editor injects when authoring this custom instrument's source: the shared CustomInstrument* surface plus the def-scoped CustomInstrumentFeatureMap and CustomInstrumentWidgetMap that narrow `config.features.<id>`, `ctx.widget(<id>)`, and the `onAction` parameter to the exact ids/series/actions you declared. Use this to write source that compiles against the same types the editor enforces.",
inputSchemaJSON: """
{"type":"object","properties":{"def_id":{"type":"string"}},"required":["def_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let defID = (invocation.args["def_id"] as? String).flatMap(UUID.init(uuidString:)) else {
return errorResult("missing or invalid def_id", code: .invalidInput)
}
guard let def = engine.customInstruments.def(withId: defID) else {
return errorResult("no custom instrument with id \(defID)", code: .notFound)
}
let scoped = CustomInstrumentTypings.defScopedDeclarations(for: def)
let payload: [String: Any] = [
"ambient": CustomInstrumentTypings.ambientDeclarations,
"scoped": scoped,
]
return makeResult(jsonObject: payload, summary: "Typings for \(def.name)")
}
}
private static func registerLookupFridaAPI(in catalog: ToolCatalog) {
let spec = ActionSpec(
name: "lookup_frida_api",
description: "Search Frida's GumJS TypeScript declarations. Returns matching declarations with their doc comments. Use this when the LLM training data may be stale (Frida 17 reorganised the Module API; symbols like findGlobalExportByName replaced the older findExportByName). Pass a function or class name as 'query' (case-insensitive substring match).",
inputSchemaJSON: """
{"type":"object","properties":{"query":{"type":"string"},"max_matches":{"type":"integer","minimum":1,"maximum":40,"default":12}},"required":["query"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { invocation in
guard let query = (invocation.args["query"] as? String)?.trimmingCharacters(in: .whitespaces), !query.isEmpty else {
return errorResult("missing query", code: .invalidInput)
}
let fridaGumSource = TypeScriptTypings.fridaGum.map(\.content).joined(separator: "\n")
let cap = (invocation.args["max_matches"] as? Int) ?? 12
let matches = searchFridaDeclarations(in: fridaGumSource, query: query, limit: cap)
let payload: [String: Any] = [
"query": query,
"match_count": matches.count,
"matches": matches,
]
return makeResult(
jsonObject: payload,
summary: matches.isEmpty ? "No declarations matched '\(query)'" : "\(matches.count) declaration\(matches.count == 1 ? "" : "s") matched '\(query)'"
)
}
}
private static func tracerHandlerTemplate(kind: String) -> String? {
switch kind {
case "instruction":
return defaultTracerCode(kind: .instruction, anchor: .absolute(0), displayName: "myTarget")
case "native":
return defaultTracerCode(kind: .function, anchor: .absolute(0), displayName: "myTarget")
case "objc":
return defaultTracerCode(kind: .function, anchor: .objcMethod(selector: "-[MyClass doThing:]"), displayName: "-[MyClass doThing:]")
case "swift":
return defaultTracerCode(kind: .function, anchor: .swiftFunc(module: "MyModule", function: "MyType.doThing()"), displayName: "MyModule.MyType.doThing()")
case "java":
return defaultTracerCode(kind: .function, anchor: .javaMethod(className: "com.example.MyClass", methodName: "doThing"), displayName: "com.example.MyClass.doThing")
default:
return nil
}
}
private static func searchFridaDeclarations(in source: String, query: String, limit: Int) -> [[String: String]] {
let blocks = fridaDeclarationBlocks(from: source)
let needle = query.lowercased()
var matches: [[String: String]] = []
for block in blocks {
if block.declaration.lowercased().contains(needle) {
matches.append([
"declaration": block.declaration,
"doc": block.doc,
])
if matches.count >= limit { break }
}
}
return matches
}
private struct FridaBlock {
var doc: String
var declaration: String
}
private static func fridaDeclarationBlocks(from source: String) -> [FridaBlock] {
var blocks: [FridaBlock] = []
var docLines: [String] = []
var inDoc = false
for raw in source.split(separator: "\n", omittingEmptySubsequences: false) {
let line = String(raw)
let trimmed = line.trimmingCharacters(in: .whitespaces)
if inDoc {
docLines.append(line)
if trimmed.hasSuffix("*/") { inDoc = false }
continue
}
if trimmed.hasPrefix("/**") {
docLines = [line]
if trimmed.hasSuffix("*/") {
inDoc = false
} else {
inDoc = true
}
continue
}
if trimmed.isEmpty || trimmed.hasPrefix("//") {
docLines = []
continue
}
blocks.append(FridaBlock(doc: docLines.joined(separator: "\n"), declaration: line))
docLines = []
}
return blocks
}
private static func registerListPackages(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "list_packages",
description: "List npm packages installed in the project's compiler workspace. These are available to all sessions, custom instruments, and tracer hooks. Common ones: frida-java-bridge (Android Java tracing), frida-objc-bridge.",
inputSchemaJSON: """
{"type":"object","properties":{},"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] _ in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
let array: [[String: Any]] = engine.installedPackages.map { pkg in
var entry: [String: Any] = [
"name": pkg.name,
"version": pkg.version,
]
if let alias = pkg.globalAlias, !alias.isEmpty {
entry["global_alias"] = alias
}
return entry
}
return makeResult(jsonObject: array, summary: "\(array.count) installed package\(array.count == 1 ? "" : "s")")
}
}
private static func registerInstallPackage(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "install_package",
description: "Install an npm package into the project's compiler workspace. Use this to enable runtime bridges (e.g. frida-java-bridge for Java tracing). Requires user approval.",
inputSchemaJSON: """
{"type":"object","properties":{"name":{"type":"string"},"version":{"type":"string","description":"npm semver range (e.g. ^7.0.0). Omit for latest."},"global_alias":{"type":"string","description":"Optional global identifier the package should expose at runtime (e.g. 'Java' for frida-java-bridge)."}},"required":["name"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let name = invocation.args["name"] as? String, !name.isEmpty else {
return errorResult("missing name", code: .invalidInput)
}
let version = invocation.args["version"] as? String
let globalAlias = invocation.args["global_alias"] as? String
do {
let pkg = try await engine.installPackage(name: name, versionSpec: version, globalAlias: globalAlias)
let payload: [String: Any] = [
"name": pkg.name,
"version": pkg.version,
]
return makeResult(jsonObject: payload, summary: "Installed \(pkg.name)@\(pkg.version)")
} catch {
return errorResult("install failed: \(error.localizedDescription)")
}
}
}
private static func registerRemovePackage(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "remove_package",
description: "Remove a previously installed npm package from the project's compiler workspace. Requires user approval.",
inputSchemaJSON: """
{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let name = invocation.args["name"] as? String, !name.isEmpty else {
return errorResult("missing name", code: .invalidInput)
}
guard let pkg = engine.installedPackages.first(where: { $0.name == name }) else {
return errorResult("no installed package named '\(name)'", code: .notFound)
}
do {
try await engine.removePackage(pkg)
return makeResult(jsonObject: ["name": name, "removed": true], summary: "Removed \(name)")
} catch {
return errorResult("remove failed: \(error.localizedDescription)")
}
}
}
private static func registerStartThreadTrace(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "start_thread_trace",
description: "Start an instruction trace for a specific thread. Returns trace_id; pair with stop_trace once you've triggered the behavior you want to observe. Trace data is bounded by the agent's ring buffer, but the longer it runs the more is captured — keep it short.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"thread_id":{"type":"integer","minimum":0},"thread_name":{"type":"string"}},"required":["session_id","thread_id"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let threadIDRaw = invocation.args["thread_id"] as? Int, threadIDRaw >= 0 else {
return errorResult("missing or invalid thread_id", code: .invalidInput)
}
let threadName = invocation.args["thread_name"] as? String
guard let trace = await engine.startThreadTrace(sessionID: sessionID, threadID: UInt(threadIDRaw), threadName: threadName) else {
return errorResult("failed to start thread trace")
}
return makeResult(jsonObject: traceListEntry(trace), summary: "Started \(trace.displayName)")
}
}
private static func registerStopTrace(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "stop_trace",
description: "Stop a running instruction trace. After this returns, summarize_trace and the read_* tools have a stable picture.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"trace_id":{"type":"string"}},"required":["session_id","trace_id"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let traceID = (invocation.args["trace_id"] as? String).flatMap(UUID.init(uuidString:)) else {
return errorResult("missing or invalid trace_id", code: .invalidInput)
}
await engine.stopThreadTrace(traceID: traceID, sessionID: sessionID)
return makeResult(jsonObject: ["trace_id": traceID.uuidString, "stopped": true], summary: "Stopped trace \(traceID)")
}
}
private static func registerListTraces(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "list_traces",
description: "List instruction traces saved on this session — id, origin (functionCall hookID/callIndex or thread tid), display name, status, byte size. Use summarize_trace before reading raw entries.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"}},"required":["session_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
let traces = engine.tracesBySession[sessionID] ?? []
let array = traces.map(traceListEntry)
return makeResult(jsonObject: array, summary: "\(traces.count) trace\(traces.count == 1 ? "" : "s")")
}
}
private static func registerSummarizeTrace(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "summarize_trace",
description: "Summarize a trace: total entries, function-call count, top-N functions by entry count. Cheap; doesn't return raw entries.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"trace_id":{"type":"string"},"top_n":{"type":"integer","minimum":1,"maximum":50,"default":10}},"required":["session_id","trace_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let traceID = (invocation.args["trace_id"] as? String).flatMap(UUID.init(uuidString:)) else {
return errorResult("missing or invalid trace_id", code: .invalidInput)
}
guard let decoded = await engine.decodeTrace(traceID: traceID, sessionID: sessionID) else {
return errorResult("could not decode trace \(traceID)")
}
let topN = (invocation.args["top_n"] as? Int) ?? 10
let topCalls = decoded.functionCalls
.sorted { $0.entryCount > $1.entryCount }
.prefix(topN)
.map { call -> [String: Any] in
[
"function_name": call.functionName,
"entry_count": call.entryCount,
"start_index": call.startIndex,
"end_index": call.endIndex,
]
}
let firstAddr = decoded.entries.first.map { String(format: "0x%llx", $0.blockAddress) }
let lastAddr = decoded.entries.last.map { String(format: "0x%llx", $0.blockAddress) }
var payload: [String: Any] = [
"trace_id": traceID.uuidString,
"entry_count": decoded.entries.count,
"function_call_count": decoded.functionCalls.count,
"register_count": decoded.registerNames.count,
"top_function_calls": topCalls,
]
if let firstAddr { payload["first_block_address"] = firstAddr }
if let lastAddr { payload["last_block_address"] = lastAddr }
return makeResult(jsonObject: payload, summary: "\(decoded.entries.count) entries, \(decoded.functionCalls.count) function calls")
}
}
private static func registerListTraceFunctionCalls(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "list_trace_function_calls",
description: "Paginate through function calls observed in a trace. Returns name, entry range, and entry count — no addresses or register state.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"trace_id":{"type":"string"},"offset":{"type":"integer","minimum":0,"default":0},"limit":{"type":"integer","minimum":1,"maximum":200,"default":50}},"required":["session_id","trace_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let traceID = (invocation.args["trace_id"] as? String).flatMap(UUID.init(uuidString:)) else {
return errorResult("missing or invalid trace_id", code: .invalidInput)
}
guard let decoded = await engine.decodeTrace(traceID: traceID, sessionID: sessionID) else {
return errorResult("could not decode trace \(traceID)")
}
let offset = (invocation.args["offset"] as? Int) ?? 0
let limit = (invocation.args["limit"] as? Int) ?? 50
let calls = decoded.functionCalls
let slice = calls.dropFirst(offset).prefix(limit)
let array = slice.enumerated().map { idx, call -> [String: Any] in
[
"index": offset + idx,
"function_name": call.functionName,
"start_index": call.startIndex,
"end_index": call.endIndex,
"entry_count": call.entryCount,
]
}
let payload: [String: Any] = [
"trace_id": traceID.uuidString,
"total": calls.count,
"offset": offset,
"function_calls": array,
]
return makeResult(jsonObject: payload, summary: "\(slice.count) of \(calls.count) function calls")
}
}
private static func registerReadTraceFunctionCall(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "read_trace_function_call",
description: "Read the basic blocks executed within one function call (resolved by index from list_trace_function_calls). Returns each block's address and size, capped to max_blocks. Pair with the disassemble tool for instruction text.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"trace_id":{"type":"string"},"call_index":{"type":"integer","minimum":0},"max_blocks":{"type":"integer","minimum":1,"maximum":1000,"default":200}},"required":["session_id","trace_id","call_index"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let traceID = (invocation.args["trace_id"] as? String).flatMap(UUID.init(uuidString:)) else {
return errorResult("missing or invalid trace_id", code: .invalidInput)
}
guard let callIndex = invocation.args["call_index"] as? Int, callIndex >= 0 else {
return errorResult("missing or invalid call_index", code: .invalidInput)
}
guard let decoded = await engine.decodeTrace(traceID: traceID, sessionID: sessionID) else {
return errorResult("could not decode trace \(traceID)")
}
guard callIndex < decoded.functionCalls.count else {
return errorResult("call_index \(callIndex) out of range (\(decoded.functionCalls.count) calls)", code: .invalidInput)
}
let call = decoded.functionCalls[callIndex]
let maxBlocks = (invocation.args["max_blocks"] as? Int) ?? 200
let cappedEnd = min(call.endIndex, call.startIndex + maxBlocks)
let blocks = decoded.entries[call.startIndex..<cappedEnd].enumerated().map { offset, entry -> [String: Any] in
[
"entry_index": call.startIndex + offset,
"address": String(format: "0x%llx", entry.blockAddress),
"size": entry.blockSize,
"name": entry.blockName,
]
}
let payload: [String: Any] = [
"trace_id": traceID.uuidString,
"call_index": callIndex,
"function_name": call.functionName,
"start_index": call.startIndex,
"end_index": call.endIndex,
"returned": blocks.count,
"truncated": blocks.count < call.entryCount,
"blocks": blocks,
]
return makeResult(jsonObject: payload, summary: "\(blocks.count) of \(call.entryCount) blocks for \(call.shortName)")
}
}
private static func registerReadTraceRegisterState(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "read_trace_register_state",
description: "Return the register state at a specific entry in a trace. Useful for spotting argument or return values around a particular block. Pass the entry_index you got from read_trace_function_call.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"trace_id":{"type":"string"},"entry_index":{"type":"integer","minimum":0}},"required":["session_id","trace_id","entry_index"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let traceID = (invocation.args["trace_id"] as? String).flatMap(UUID.init(uuidString:)) else {
return errorResult("missing or invalid trace_id", code: .invalidInput)
}
guard let entryIndex = invocation.args["entry_index"] as? Int, entryIndex >= 0 else {
return errorResult("missing or invalid entry_index", code: .invalidInput)
}
guard let decoded = await engine.decodeTrace(traceID: traceID, sessionID: sessionID) else {
return errorResult("could not decode trace \(traceID)")
}
guard entryIndex < decoded.registerStates.count else {
return errorResult("entry_index \(entryIndex) out of range (\(decoded.registerStates.count) entries)", code: .invalidInput)
}
let state = decoded.registerStates[entryIndex]
var values: [String: String] = [:]
for (idx, value) in state.values {
guard idx < decoded.registerNames.count else { continue }
values[decoded.registerNames[idx]] = String(format: "0x%llx", value)
}
let changed = state.changed.compactMap { idx -> String? in
guard idx < decoded.registerNames.count else { return nil }
return decoded.registerNames[idx]
}
let payload: [String: Any] = [
"trace_id": traceID.uuidString,
"entry_index": entryIndex,
"registers": values,
"changed": changed,
]
return makeResult(jsonObject: payload, summary: "Register state at entry \(entryIndex)")
}
}
private static func traceListEntry(_ trace: ITrace) -> [String: Any] {
var entry: [String: Any] = [
"trace_id": trace.id.uuidString,
"display_name": trace.displayName,
"status": trace.isRunning ? "running" : "stopped",
"data_size": trace.dataSize,
"started_at": ISO8601DateFormatter().string(from: trace.startedAt),
]
if let stoppedAt = trace.stoppedAt {
entry["stopped_at"] = ISO8601DateFormatter().string(from: stoppedAt)
}
switch trace.origin {
case .functionCall(let hookID, let callIndex):
entry["origin"] = [
"kind": "function_call",
"hook_id": hookID.uuidString,
"call_index": callIndex,
]
case .thread(let tid, let name):
var origin: [String: Any] = [
"kind": "thread",
"thread_id": tid,
]
if let name { origin["thread_name"] = name }
entry["origin"] = origin
}
return entry
}
private static func describeIcon(_ icon: InstrumentIcon) -> String {
switch icon {
case .symbolic(let id): return id
case .pixels: return "pixels"
}
}
private static func parseIconArg(_ raw: String?) -> InstrumentIcon {
guard let raw, !raw.isEmpty else {
return .symbolic(InstrumentIconCatalog.default.id)
}
return .symbolic(InstrumentIconCatalog.concept(forID: raw).id)
}
private static let compatibilitySchemaJSON: String = """
{"type":"object","description":"Optional platform/OS/arch gate. Omit a field to leave that axis unconstrained; pass an empty object to clear all constraints.","properties":{"platforms":{"type":"array","items":{"type":"string","enum":["windows","darwin","linux","freebsd","qnx","barebone"]}},"osIDs":{"type":"array","items":{"type":"string","enum":["windows","macos","linux","ios","watchos","tvos","visionos","android","freebsd","qnx"]}},"archs":{"type":"array","items":{"type":"string","enum":["ia32","x64","arm","arm64","mips"]}}},"additionalProperties":false}
"""
private static func parseCompatibilityArg(_ raw: Any?) -> InstrumentCompatibility {
guard let obj = raw as? [String: Any] else { return .universal }
let platforms = (obj["platforms"] as? [String]).map(Set.init)
let osIDs = (obj["osIDs"] as? [String]).map(Set.init)
let archs = (obj["archs"] as? [String]).map(Set.init)
return InstrumentCompatibility(platforms: platforms, osIDs: osIDs, archs: archs)
}
private static func parseFeaturesArg(_ raw: Any?) -> [CustomInstrumentDef.Feature] {
guard let arr = raw as? [[String: Any]] else { return [] }
return arr.compactMap { obj in
guard let id = obj["id"] as? String, let name = obj["name"] as? String else { return nil }
let enabled = (obj["enabled_by_default"] as? Bool) ?? true
return CustomInstrumentDef.Feature(id: id, name: name, schema: .boolean(default: enabled), optional: false, enabledByDefault: enabled)
}
}
private static let widgetsSchemaJSON: String = """
{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"kind":{"type":"string","enum":["counter","histogram","graph","list","table","hex","console"]},"persistence":{"type":"string","enum":["none","session"],"default":"none","description":"'none' = data lives in memory, lost on detach. 'session' = data survives reattach and app restart, replayed to create() via the `restored` argument."},"max_points":{"type":"integer","minimum":1,"default":5000,"description":"For kind=graph: rolling cap per series. Oldest points drop when exceeded."},"max_items":{"type":"integer","minimum":1,"default":1000,"description":"For kind=list: rolling cap. Oldest items drop when exceeded."},"max_rows":{"type":"integer","minimum":1,"default":1000,"description":"For kind=table: rolling cap on rows."},"max_buckets":{"type":"integer","minimum":1,"default":100,"description":"For kind=histogram: rolling cap on bucket count."},"max_bytes":{"type":"integer","minimum":1,"default":16384,"description":"For kind=hex: trailing-window cap on bytes shown."},"max_entries":{"type":"integer","minimum":1,"default":1000,"description":"For kind=console: rolling cap on entries shown. Oldest entries drop when exceeded."},"unit":{"type":"string","description":"For kind=counter: optional default unit label rendered next to the value."},"prompt":{"type":"string","description":"For kind=console: optional prompt glyph rendered before each entry and on the input row (default '›')."},"placeholder":{"type":"string","description":"For kind=console: optional placeholder text shown in the input field."},"run_button_label":{"type":"string","description":"For kind=console: optional label for the submit button (default 'Run')."},"series":{"type":"array","description":"For kind=graph: line series the agent will push points to.","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":false}},"columns":{"type":"array","description":"For kind=table: ordered columns; each row's cells map column id to string.","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"alignment":{"type":"string","enum":["leading","trailing"],"default":"leading"}},"required":["id","name"],"additionalProperties":false}},"actions":{"type":"array","description":"For kind=list/table: per-item action buttons; clicks invoke onAction with {widget,action,item}.","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":false}}},"required":["id","name","kind"],"additionalProperties":false}}
"""
private static func parseWidgetsArg(_ raw: Any?) -> [InstrumentWidget] {
guard let arr = raw as? [[String: Any]] else { return [] }
return arr.compactMap { obj in
guard let id = obj["id"] as? String,
let name = obj["name"] as? String,
let kindStr = obj["kind"] as? String
else { return nil }
let persistence = (obj["persistence"] as? String).flatMap(InstrumentWidget.Persistence.init(rawValue:)) ?? .none
switch kindStr {
case "counter":
let cfg = InstrumentWidget.CounterConfig(unit: obj["unit"] as? String)
return InstrumentWidget(id: id, name: name, kind: .counter(cfg), persistence: persistence)
case "histogram":
let maxBuckets = (obj["max_buckets"] as? Int) ?? InstrumentWidget.HistogramConfig.defaultMaxBuckets
return InstrumentWidget(id: id, name: name, kind: .histogram(InstrumentWidget.HistogramConfig(maxBuckets: max(1, maxBuckets))), persistence: persistence)
case "graph":
let cfg = parseGraphConfigArg(seriesRaw: obj["series"], maxPointsRaw: obj["max_points"])
return InstrumentWidget(id: id, name: name, kind: .graph(cfg), persistence: persistence)
case "list":
let cfg = parseListConfigArg(actionsRaw: obj["actions"], maxItemsRaw: obj["max_items"])
return InstrumentWidget(id: id, name: name, kind: .list(cfg), persistence: persistence)
case "table":
let cfg = parseTableConfigArg(columnsRaw: obj["columns"], actionsRaw: obj["actions"], maxRowsRaw: obj["max_rows"])
return InstrumentWidget(id: id, name: name, kind: .table(cfg), persistence: persistence)
case "hex":
let maxBytes = (obj["max_bytes"] as? Int) ?? InstrumentWidget.HexConfig.defaultMaxBytes
return InstrumentWidget(id: id, name: name, kind: .hex(InstrumentWidget.HexConfig(maxBytes: max(1, maxBytes))), persistence: persistence)
case "console":
let cfg = parseConsoleConfigArg(
promptRaw: obj["prompt"],
placeholderRaw: obj["placeholder"],
runLabelRaw: obj["run_button_label"],
maxEntriesRaw: obj["max_entries"]
)
return InstrumentWidget(id: id, name: name, kind: .console(cfg), persistence: persistence)
default:
return nil
}
}
}
private static func parseGraphConfigArg(seriesRaw: Any?, maxPointsRaw: Any?) -> InstrumentWidget.GraphConfig {
let entries = (seriesRaw as? [[String: Any]]) ?? []
let series = entries.compactMap { obj -> InstrumentWidget.Series? in
guard let sid = obj["id"] as? String, let sname = obj["name"] as? String else { return nil }
return InstrumentWidget.Series(id: sid, name: sname)
}
let maxPoints = (maxPointsRaw as? Int) ?? InstrumentWidget.GraphConfig.defaultMaxPoints
return InstrumentWidget.GraphConfig(series: series, maxPoints: max(1, maxPoints))
}
private static func parseListConfigArg(actionsRaw: Any?, maxItemsRaw: Any?) -> InstrumentWidget.ListConfig {
let entries = (actionsRaw as? [[String: Any]]) ?? []
let actions = entries.compactMap { obj -> InstrumentWidget.Action? in
guard let aid = obj["id"] as? String, let aname = obj["name"] as? String else { return nil }
return InstrumentWidget.Action(id: aid, name: aname)
}
let maxItems = (maxItemsRaw as? Int) ?? InstrumentWidget.ListConfig.defaultMaxItems
return InstrumentWidget.ListConfig(actions: actions, maxItems: max(1, maxItems))
}
private static func parseTableConfigArg(columnsRaw: Any?, actionsRaw: Any?, maxRowsRaw: Any?) -> InstrumentWidget.TableConfig {
let columnEntries = (columnsRaw as? [[String: Any]]) ?? []
let columns = columnEntries.compactMap { obj -> InstrumentWidget.Column? in
guard let cid = obj["id"] as? String, let cname = obj["name"] as? String else { return nil }
let alignment = (obj["alignment"] as? String).flatMap(InstrumentWidget.Column.Alignment.init(rawValue:)) ?? .leading
return InstrumentWidget.Column(id: cid, name: cname, alignment: alignment)
}
let actionEntries = (actionsRaw as? [[String: Any]]) ?? []
let actions = actionEntries.compactMap { obj -> InstrumentWidget.Action? in
guard let aid = obj["id"] as? String, let aname = obj["name"] as? String else { return nil }
return InstrumentWidget.Action(id: aid, name: aname)
}
let maxRows = (maxRowsRaw as? Int) ?? InstrumentWidget.TableConfig.defaultMaxRows
return InstrumentWidget.TableConfig(columns: columns, actions: actions, maxRows: max(1, maxRows))
}
private static func parseConsoleConfigArg(
promptRaw: Any?,
placeholderRaw: Any?,
runLabelRaw: Any?,
maxEntriesRaw: Any?
) -> InstrumentWidget.ConsoleConfig {
let maxEntries = (maxEntriesRaw as? Int) ?? InstrumentWidget.ConsoleConfig.defaultMaxEntries
return InstrumentWidget.ConsoleConfig(
prompt: optionalString(promptRaw),
placeholder: optionalString(placeholderRaw),
runButtonLabel: optionalString(runLabelRaw),
maxEntries: max(1, maxEntries)
)
}
private static func optionalString(_ raw: Any?) -> String? {
guard let str = raw as? String, !str.isEmpty else { return nil }
return str
}
private static func customInstrumentJSON(def: CustomInstrumentDef, files: [CustomInstrumentFile]) -> [String: Any] {
let features: [[String: Any]] = def.features.map { feature in
[
"id": feature.id,
"name": feature.name,
"enabled_by_default": feature.enabledByDefault,
]
}
let widgets: [[String: Any]] = def.widgets.map(customInstrumentWidgetJSON)
return [
"id": def.id.uuidString,
"name": def.name,
"icon": describeIcon(def.icon),
"entrypoint": def.entrypoint,
"paths": files.map(\.path).sorted(),
"features": features,
"widgets": widgets,
]
}
private static func withResolvedCustomInstrumentDef(
_ args: [String: Any],
engine engineMaybe: Engine?,
body: @MainActor (Engine, UUID, CustomInstrumentDef) async -> ActionResult
) async -> ActionResult {
guard let engine = engineMaybe else { return errorResult("engine unavailable", code: .unavailable) }
guard let defID = (args["def_id"] as? String).flatMap(UUID.init(uuidString:)) else {
return errorResult("missing or invalid def_id", code: .invalidInput)
}
guard let def = engine.customInstruments.def(withId: defID) else {
return errorResult("no custom instrument with id \(defID)", code: .notFound)
}
return await body(engine, defID, def)
}
private static func withResolvedCustomInstrumentFile(
_ args: [String: Any],
engine engineMaybe: Engine?,
body: @MainActor (Engine, UUID, CustomInstrumentDef, CustomInstrumentFile) async -> ActionResult
) async -> ActionResult {
await withResolvedCustomInstrumentDef(args, engine: engineMaybe) { engine, defID, def in
guard let path = args["path"] as? String, !path.isEmpty else {
return errorResult("missing path", code: .invalidInput)
}
guard let file = engine.customInstruments.file(defID: defID, path: path) else {
return errorResult("no file '\(path)' in custom instrument \(defID)", code: .notFound)
}
return await body(engine, defID, def, file)
}
}
private static func customInstrumentWidgetJSON(_ widget: InstrumentWidget) -> [String: Any] {
var obj: [String: Any] = [
"id": widget.id,
"name": widget.name,
"persistence": widget.persistence.rawValue,
]
switch widget.kind {
case .counter(let cfg):
obj["kind"] = "counter"
if let unit = cfg.unit { obj["unit"] = unit }
case .histogram(let cfg):
obj["kind"] = "histogram"
obj["max_buckets"] = cfg.maxBuckets
case .graph(let cfg):
obj["kind"] = "graph"
obj["series"] = cfg.series.map { ["id": $0.id, "name": $0.name] }
obj["max_points"] = cfg.maxPoints
case .list(let cfg):
obj["kind"] = "list"
obj["actions"] = cfg.actions.map { ["id": $0.id, "name": $0.name] }
obj["max_items"] = cfg.maxItems
case .table(let cfg):
obj["kind"] = "table"
obj["columns"] = cfg.columns.map { ["id": $0.id, "name": $0.name, "alignment": $0.alignment.rawValue] }
obj["actions"] = cfg.actions.map { ["id": $0.id, "name": $0.name] }
obj["max_rows"] = cfg.maxRows
case .hex(let cfg):
obj["kind"] = "hex"
obj["max_bytes"] = cfg.maxBytes
case .console(let cfg):
obj["kind"] = "console"
if let prompt = cfg.prompt { obj["prompt"] = prompt }
if let placeholder = cfg.placeholder { obj["placeholder"] = placeholder }
if let label = cfg.runButtonLabel { obj["run_button_label"] = label }
obj["max_entries"] = cfg.maxEntries
}
return obj
}
private static func registerRecordFinding(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "record_finding",
description: "Record a grounded finding. Every finding must reference at least one prior tool call (action) by its tool_call_id, or an event_id from summarize_recent_events. Findings without evidence are rejected.",
inputSchemaJSON: """
{"type":"object","properties":{"title":{"type":"string"},"body_markdown":{"type":"string"},"confidence":{"type":"string","enum":["low","medium","high"]},"kind":{"type":"string"},"session_id":{"type":"string"},"evidence":{"type":"array","minItems":1,"items":{"type":"object","properties":{"kind":{"type":"string","enum":["action","event","disasm_span","memory_read","symbol_match","insight"]},"ref":{"type":"object","description":"Either {tool_call_id} for action/observe results, or {event_id}, or a free ref"}},"required":["kind","ref"]}}},"required":["title","body_markdown","confidence","kind","evidence"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let title = invocation.args["title"] as? String,
let body = invocation.args["body_markdown"] as? String,
let confidenceStr = invocation.args["confidence"] as? String,
let confidence = MissionFindingConfidence(rawValue: confidenceStr),
let kind = invocation.args["kind"] as? String,
let evidenceList = invocation.args["evidence"] as? [[String: Any]],
!evidenceList.isEmpty
else {
return errorResult("invalid arguments — title, body_markdown, confidence, kind, and non-empty evidence are required", code: .invalidInput)
}
let actions = (try? engine.store.fetchMissionActions(missionID: invocation.mission.id)) ?? []
let knownToolCallIDs = Set(actions.compactMap { $0.toolCallID })
var validatedEvidence: [(MissionEvidenceKind, [String: Any])] = []
for entry in evidenceList {
guard let kindStr = entry["kind"] as? String,
let evKind = MissionEvidenceKind(rawValue: kindStr),
let ref = entry["ref"] as? [String: Any]
else {
return errorResult("evidence entry malformed: \(entry)", code: .invalidInput)
}
if evKind == .action {
guard let cid = ref["tool_call_id"] as? String,
knownToolCallIDs.contains(cid)
else {
return errorResult("evidence references unknown tool_call_id; this finding is not grounded", code: .invalidInput)
}
}
validatedEvidence.append((evKind, ref))
}
let sessionID = parseSessionID(invocation.args)
let finding = MissionFinding(
missionID: invocation.mission.id,
title: title,
bodyMarkdown: body,
confidence: confidence,
kind: kind,
sessionID: sessionID
)
do {
try engine.store.save(finding)
engine.collaboration.enqueueMissionFinding(finding)
for (evKind, ref) in validatedEvidence {
let refData = try JSONSerialization.data(withJSONObject: ref, options: [.sortedKeys])
let refJSON = String(data: refData, encoding: .utf8) ?? "{}"
let evidence = MissionEvidence(findingID: finding.id, kind: evKind, refJSON: refJSON)
try engine.store.save(evidence)
engine.collaboration.enqueueMissionEvidence(missionID: invocation.mission.id, evidence: evidence)
}
} catch {
return errorResult("could not persist finding: \(error.localizedDescription)")
}
let payload: [String: Any] = [
"finding_id": finding.id.uuidString,
"title": title,
"confidence": confidence.rawValue,
"evidence_count": validatedEvidence.count,
]
return makeResult(
jsonObject: payload,
summary: "Recorded finding \"\(title)\" (\(confidence.rawValue), \(validatedEvidence.count) evidence)"
)
}
}
private static func registerListFindings(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "list_findings",
description: "List findings recorded during this mission. Returns id, title, confidence, kind, status, session_id, and created_at. Use to remember what you've already grounded so you don't double-record.",
inputSchemaJSON: """
{"type":"object","properties":{"status":{"type":"string","enum":["proposed","accepted","refuted","superseded"]},"confidence":{"type":"string","enum":["low","medium","high"]},"limit":{"type":"integer","minimum":1,"maximum":500,"description":"Max results (default 100)"}},"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
let limit = (invocation.args["limit"] as? Int) ?? 100
let statusFilter = (invocation.args["status"] as? String).flatMap(MissionFindingStatus.init(rawValue:))
let confidenceFilter = (invocation.args["confidence"] as? String).flatMap(MissionFindingConfidence.init(rawValue:))
let findings = (try? engine.store.fetchMissionFindings(missionID: invocation.mission.id)) ?? []
let filtered = findings.filter { f in
(statusFilter == nil || f.status == statusFilter) &&
(confidenceFilter == nil || f.confidence == confidenceFilter)
}
let array = filtered.suffix(limit).map(findingSummaryJSON)
return makeResult(jsonObject: Array(array), summary: "\(array.count) finding\(array.count == 1 ? "" : "s")")
}
}
private static func findingSummaryJSON(_ finding: MissionFinding) -> [String: Any] {
var obj: [String: Any] = [
"finding_id": finding.id.uuidString,
"title": finding.title,
"confidence": finding.confidence.rawValue,
"kind": finding.kind,
"status": finding.status.rawValue,
"created_at": ISO8601DateFormatter().string(from: finding.createdAt),
]
if let sessionID = finding.sessionID {
obj["session_id"] = sessionID.uuidString
}
return obj
}
private static func registerReadFinding(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "read_finding",
description: "Read the full body and evidence list for a finding. Use after list_findings to fetch markdown and the evidence references that grounded the claim.",
inputSchemaJSON: """
{"type":"object","properties":{"finding_id":{"type":"string"}},"required":["finding_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let idString = invocation.args["finding_id"] as? String,
let findingID = UUID(uuidString: idString)
else {
return errorResult("missing or invalid finding_id", code: .invalidInput)
}
let findings = (try? engine.store.fetchMissionFindings(missionID: invocation.mission.id)) ?? []
guard let finding = findings.first(where: { $0.id == findingID }) else {
return errorResult("no finding with id \(findingID)", code: .notFound)
}
let evidence = (try? engine.store.fetchMissionEvidence(findingID: findingID)) ?? []
var obj = findingSummaryJSON(finding)
obj["body_markdown"] = finding.bodyMarkdown
obj["evidence"] = evidence.map(evidenceJSON)
return makeResult(jsonObject: obj, summary: "Read finding \"\(finding.title)\"")
}
}
private static func evidenceJSON(_ evidence: MissionEvidence) -> [String: Any] {
var obj: [String: Any] = ["kind": evidence.kind.rawValue]
if let data = evidence.refJSON.data(using: .utf8),
let ref = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
{
obj["ref"] = ref
}
return obj
}
private static func registerR2Cmd(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "r2_cmd",
description: "Run a radare2 command in this session's r2 context and return its stdout. Use for ad-hoc queries the typed tools don't cover (e.g. axt for xrefs, izz~pattern for strings, iiq for imports, afi for function info). The r2 instance analyses the target process via Frida-backed memory IO; commands cannot affect the live process. Output is truncated to max_output_chars.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"command":{"type":"string","description":"r2 command line, e.g. 'axt @ 0x1004500' or 'izz~http'"},"max_output_chars":{"type":"integer","minimum":256,"maximum":262144,"default":32768}},"required":["session_id","command"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let command = (invocation.args["command"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
!command.isEmpty
else {
return errorResult("missing or empty command", code: .invalidInput)
}
guard let dis = engine.disassembler(forSessionID: sessionID) else {
return errorResult("no disassembler for session", code: .notFound)
}
let limit = (invocation.args["max_output_chars"] as? Int) ?? 32_768
let result = await dis.runCommand(command)
let (text, wasTruncated) = truncated(result.output ?? "", to: limit)
var payload: [String: Any] = ["command": command, "output": text]
if wasTruncated {
payload["truncated"] = true
payload["original_chars"] = (result.output ?? "").count
}
if !result.logs.isEmpty {
payload["logs"] = result.logs.map(logEntryToJSON)
}
let suffix = result.hasErrors ? " (errors)" : (wasTruncated ? " (truncated)" : "")
return makeResult(jsonObject: payload, summary: "r2: \(command)\(suffix)")
}
}
private static func logEntryToJSON(_ entry: R2LogEntry) -> [String: Any] {
var obj: [String: Any] = [
"level": logLevelName(entry.level),
"message": entry.message,
]
if let origin = entry.origin {
obj["origin"] = origin
}
return obj
}
private static func logLevelName(_ level: R2LogLevel) -> String {
switch level {
case .fatal: return "fatal"
case .error: return "error"
case .warn: return "warn"
case .info: return "info"
case .todo: return "todo"
case .debug: return "debug"
case .trace: return "trace"
}
}
private static func truncated(_ text: String, to limit: Int) -> (String, Bool) {
if text.count <= limit {
return (text, false)
}
let head = text.prefix(limit)
return (head + "\n[truncated]", true)
}
private static func registerDecompile(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "decompile",
description: "Pseudo-decompile a function via radare2's pdc command. Returns C-like text. Use for higher-level reasoning when raw disassembly is too verbose.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"address":{"type":"string","description":"Hex address of function start"}},"required":["session_id","address"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let addrString = invocation.args["address"] as? String,
let address = parseHexAddress(addrString)
else {
return errorResult("missing or invalid address", code: .invalidInput)
}
guard let dis = engine.disassembler(forSessionID: sessionID) else {
return errorResult("no disassembler for session", code: .notFound)
}
let text = await dis.decompile(at: address).output ?? ""
let payload: [String: Any] = ["address": addrString, "text": text]
return makeResult(jsonObject: payload, summary: "Decompiled function at \(addrString) (\(text.split(separator: "\n").count) lines)")
}
}
private static func registerExplainFunction(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "explain_function",
description: "Get a focused natural-language explanation of a function. Internally pulls the function's disassembly + pseudo-decompile from radare2 and asks the mission's LLM to summarize. Cheaper to read than raw disassembly when you just need to understand what a function does.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"address":{"type":"string","description":"Hex address of function start"},"focus":{"type":"string","description":"Optional question to focus the explanation, e.g. 'how is the password handled here'"}},"required":["session_id","address"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let addrString = invocation.args["address"] as? String,
let address = parseHexAddress(addrString)
else {
return errorResult("missing or invalid address", code: .invalidInput)
}
guard let dis = engine.disassembler(forSessionID: sessionID) else {
return errorResult("no disassembler for session", code: .notFound)
}
let focus = (invocation.args["focus"] as? String) ?? ""
let lines = await dis.disassemble(DisassemblyRequest(address: address, count: 64, appearance: .light))
let disasmText = lines.map { line in
String(format: "0x%llx", line.address) + " " + line.asmText.plainText
}.joined(separator: "\n")
let decompText = await dis.decompile(at: address).output ?? ""
let system = "You are a concise reverse-engineering assistant. Given disassembly and a pseudo-decompile of a function, produce a 2-4 sentence explanation of what the function does. Be specific about what the function reads/writes/calls. Do not restate the input."
var user = "Address: \(addrString)\n\nDisassembly:\n\(disasmText)\n\nPseudo-C:\n\(decompText)\n"
if !focus.isEmpty {
user += "\nFocus on: \(focus)\n"
}
let outcome = await runLLMQuery(
engine: engine,
providerID: invocation.mission.providerID,
modelID: invocation.mission.modelID,
system: system,
user: user
)
switch outcome {
case .success(let explanation):
let payload: [String: Any] = ["address": addrString, "explanation": explanation]
return makeResult(jsonObject: payload, summary: "Explained function at \(addrString)")
case .failure(let reason):
return errorResult("explanation failed: \(reason)")
}
}
}
private static func registerSuggestFunctionName(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "suggest_function_name",
description: "Propose a better name for a function based on its callers, callees, strings, and decompile. Returns a proposal only — does not rename anything in the project. The agent can choose to apply it via r2_cmd with the suggested `afn` line.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"address":{"type":"string","description":"Hex address of function start"}},"required":["session_id","address"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let addrString = invocation.args["address"] as? String,
let address = parseHexAddress(addrString)
else {
return errorResult("missing or invalid address", code: .invalidInput)
}
guard let dis = engine.disassembler(forSessionID: sessionID) else {
return errorResult("no disassembler for session", code: .notFound)
}
let hex = String(address, radix: 16)
_ = await dis.runCommand("af @ 0x\(hex)")
let xrefs = await dis.runCommand("axff~$[3] @ 0x\(hex)").output ?? ""
let nearby = await dis.runCommand("fd. @ 0x\(hex)").output ?? ""
let decomp = await dis.decompile(at: address).output ?? ""
let system = "You suggest concise, descriptive function names for reverse-engineered binaries. Output exactly one line: an `afn NEWNAME` r2 command. NEWNAME must be a single alphanumeric/underscore identifier. No prose, no markdown."
let user = "Address: \(addrString)\n\nCallees / xrefs:\n\(xrefs)\n\nNearby flags:\n\(nearby)\n\nPseudo-C:\n\(decomp)\n"
let outcome = await runLLMQuery(
engine: engine,
providerID: invocation.mission.providerID,
modelID: invocation.mission.modelID,
system: system,
user: user
)
switch outcome {
case .success(let line):
let payload: [String: Any] = ["address": addrString, "afn_command": line]
return makeResult(jsonObject: payload, summary: "Name suggestion for \(addrString)")
case .failure(let reason):
return errorResult("name suggestion failed: \(reason)")
}
}
}
private static func registerSuggestFunctionSignature(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "suggest_function_signature",
description: "Propose an improved C-style signature for a function based on argument/return usage. Returns a proposal only — does not modify the project.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"address":{"type":"string","description":"Hex address of function start"}},"required":["session_id","address"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let addrString = invocation.args["address"] as? String,
let address = parseHexAddress(addrString)
else {
return errorResult("missing or invalid address", code: .invalidInput)
}
guard let dis = engine.disassembler(forSessionID: sessionID) else {
return errorResult("no disassembler for session", code: .notFound)
}
let hex = String(address, radix: 16)
_ = await dis.runCommand("af @ 0x\(hex)")
let vars = await dis.runCommand("afv @ 0x\(hex)").output ?? ""
let current = await dis.runCommand("afs @ 0x\(hex)").output ?? ""
let decomp = await dis.decompile(at: address).output ?? ""
let system = "You infer C function signatures from low-level analysis. Output exactly one line: an `afs SIGNATURE` r2 command. Do NOT print the function body. No prose, no markdown."
let user = "Variables:\n\(vars)\n\nCurrent signature:\n\(current)\n\nPseudo-C:\n\(decomp)\n"
let outcome = await runLLMQuery(
engine: engine,
providerID: invocation.mission.providerID,
modelID: invocation.mission.modelID,
system: system,
user: user
)
switch outcome {
case .success(let line):
let payload: [String: Any] = ["address": addrString, "afs_command": line]
return makeResult(jsonObject: payload, summary: "Signature suggestion for \(addrString)")
case .failure(let reason):
return errorResult("signature suggestion failed: \(reason)")
}
}
}
private static func registerSuggestLocalNames(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "suggest_local_names",
description: "Propose better names and types for local variables and arguments of a function. Returns an r2 script of `afvn`/`afvt` commands — the agent can apply them via r2_cmd.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"address":{"type":"string","description":"Hex address of function start"}},"required":["session_id","address"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let addrString = invocation.args["address"] as? String,
let address = parseHexAddress(addrString)
else {
return errorResult("missing or invalid address", code: .invalidInput)
}
guard let dis = engine.disassembler(forSessionID: sessionID) else {
return errorResult("no disassembler for session", code: .notFound)
}
let hex = String(address, radix: 16)
_ = await dis.runCommand("af @ 0x\(hex)")
let vars = await dis.runCommand("afv @ 0x\(hex)").output ?? ""
let decomp = await dis.decompile(at: address).output ?? ""
let system = "You rename local variables and arguments based on how they're used. Output an r2 script of `afvn` (rename) and `afvt` (retype) commands, one per line. No prose, no markdown."
let user = "Variables:\n\(vars)\n\nPseudo-C:\n\(decomp)\n"
let outcome = await runLLMQuery(
engine: engine,
providerID: invocation.mission.providerID,
modelID: invocation.mission.modelID,
system: system,
user: user
)
switch outcome {
case .success(let script):
let payload: [String: Any] = ["address": addrString, "script": script]
return makeResult(jsonObject: payload, summary: "Local-name suggestions for \(addrString)")
case .failure(let reason):
return errorResult("local-name suggestion failed: \(reason)")
}
}
}
private static func registerFindVulnerabilities(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "find_vulnerabilities",
description: "Inspect a function's pseudo-decompile for likely vulnerabilities or bugs. Returns a short analysis with suggested mitigations and, where relevant, a sketched exploit. Heuristic — verify before recording findings.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"address":{"type":"string","description":"Hex address of function start"}},"required":["session_id","address"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let addrString = invocation.args["address"] as? String,
let address = parseHexAddress(addrString)
else {
return errorResult("missing or invalid address", code: .invalidInput)
}
guard let dis = engine.disassembler(forSessionID: sessionID) else {
return errorResult("no disassembler for session", code: .notFound)
}
let decomp = await dis.decompile(at: address).output ?? ""
let system = "You are a security analyst. Given a function's pseudo-decompile, identify likely vulnerabilities or bugs. Do not show the input code. Produce a short analysis with: (1) findings, (2) suggested mitigations, (3) a brief exploit sketch where relevant."
let user = "Address: \(addrString)\n\nPseudo-C:\n\(decomp)\n"
let outcome = await runLLMQuery(
engine: engine,
providerID: invocation.mission.providerID,
modelID: invocation.mission.modelID,
system: system,
user: user,
maxOutputTokens: 2048
)
switch outcome {
case .success(let analysis):
let payload: [String: Any] = ["address": addrString, "analysis": analysis]
return makeResult(jsonObject: payload, summary: "Vulnerability analysis for \(addrString)")
case .failure(let reason):
return errorResult("vulnerability analysis failed: \(reason)")
}
}
}
private static func registerPinAsInsight(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "pin_as_insight",
description: "Promote a finding into a persistent AddressInsight in the session sidebar. The insight stays open across mission boundaries so the user can keep inspecting the address. Pass either a hex 'address' (resolved against the session's modules into a moduleOffset anchor) or an explicit 'anchor' object.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"finding_id":{"type":"string"},"kind":{"type":"string","enum":["disassembly","memory"],"default":"disassembly"},"address":{"type":"string","description":"Hex address (auto-anchored against modules)"},"anchor":{"type":"object","description":"Explicit AddressAnchor (matches AddressAnchor.toJSON shape)"},"title":{"type":"string"}},"required":["session_id","finding_id"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let findingIDString = invocation.args["finding_id"] as? String,
let findingID = UUID(uuidString: findingIDString),
var finding = (try? engine.store.fetchMissionFindings(missionID: invocation.mission.id))?.first(where: { $0.id == findingID })
else {
return errorResult("finding_id does not match a finding in this mission", code: .invalidInput)
}
let kindString = (invocation.args["kind"] as? String) ?? "disassembly"
let insightKind: AddressInsight.Kind = kindString == "memory" ? .memory : .disassembly
let anchor: AddressAnchor
if let anchorObj = invocation.args["anchor"] as? [String: Any] {
do {
anchor = try AddressAnchor.fromJSON(anchorObj)
} catch {
return errorResult("anchor parse failed: \(error.localizedDescription)", code: .invalidInput)
}
} else if let addrString = invocation.args["address"] as? String, let address = parseHexAddress(addrString) {
guard let node = engine.node(forSessionID: sessionID) else {
return errorResult("no attached session for id \(sessionID)", code: .notFound)
}
anchor = node.anchor(for: address)
} else {
return errorResult("must supply either 'address' or 'anchor'", code: .invalidInput)
}
let title = (invocation.args["title"] as? String) ?? finding.title
let insight = AddressInsight(sessionID: sessionID, userTitle: title, kind: insightKind, anchor: anchor)
do {
try engine.store.save(insight)
finding.pinnedInsightID = insight.id
finding.sessionID = sessionID
try engine.store.save(finding)
engine.collaboration.enqueueMissionFinding(finding)
} catch {
return errorResult("could not persist insight: \(error.localizedDescription)")
}
let payload: [String: Any] = [
"insight_id": insight.id.uuidString,
"finding_id": finding.id.uuidString,
"anchor": anchor.displayString,
"kind": kindString,
]
return makeResult(jsonObject: payload, summary: "Pinned finding \"\(title)\" as \(kindString) insight at \(anchor.displayString)")
}
}
private static func registerListAddressInsights(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "list_address_insights",
description: "List the address insights pinned in this session: id, kind (memory or disassembly), title, anchor display, byte count, and last resolved address. Use to revisit prior pins or pick stale ones for unpin_insight.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"}},"required":["session_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
let insights = engine.insightsBySession[sessionID] ?? []
let array: [[String: Any]] = insights.map { addressInsightJSON($0, engine: engine) }
return makeResult(jsonObject: array, summary: "\(array.count) insight\(array.count == 1 ? "" : "s")")
}
}
private static func registerUnpinInsight(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "unpin_insight",
description: "Remove a pinned address insight from the session sidebar. The underlying finding stays; only the persistent pin is removed.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"insight_id":{"type":"string"}},"required":["session_id","insight_id"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let idString = invocation.args["insight_id"] as? String,
let insightID = UUID(uuidString: idString)
else {
return errorResult("missing or invalid insight_id", code: .invalidInput)
}
guard (engine.insightsBySession[sessionID] ?? []).contains(where: { $0.id == insightID }) else {
return errorResult("no insight \(insightID) on session \(sessionID)", code: .notFound)
}
engine.deleteInsight(id: insightID, sessionID: sessionID)
let payload: [String: Any] = ["insight_id": insightID.uuidString, "removed": true]
return makeResult(jsonObject: payload, summary: "Unpinned insight \(insightID.uuidString.prefix(8))")
}
}
private static func registerRenameInsight(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "rename_insight",
description: "Rename a pinned address insight. Use after you've inferred what a routine does — give it a meaningful name so the new title shows in the sidebar and as an r2 flag (and as the function name if the insight sits on a function start). Keep names short, lowercase, snake or camel.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"insight_id":{"type":"string"},"title":{"type":"string","minLength":1}},"required":["session_id","insight_id","title"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let idString = invocation.args["insight_id"] as? String,
let insightID = UUID(uuidString: idString)
else {
return errorResult("missing or invalid insight_id", code: .invalidInput)
}
guard let raw = invocation.args["title"] as? String else {
return errorResult("missing title", code: .invalidInput)
}
let title = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else {
return errorResult("title must be non-empty", code: .invalidInput)
}
guard let insight = (engine.insightsBySession[sessionID] ?? []).first(where: { $0.id == insightID }) else {
return errorResult("no insight \(insightID) on session \(sessionID)", code: .notFound)
}
engine.renameInsight(insight, to: title)
let payload: [String: Any] = ["insight_id": insightID.uuidString, "title": title]
return makeResult(jsonObject: payload, summary: "Renamed insight to \"\(title)\"")
}
}
private static func registerReadWidgetState(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "read_widget_state",
description: "Read the current cached state of a widget on a running instrument instance. Returns graph series points, list items, table rows, counter value, histogram buckets, hex bytes (base64), or console entries — whichever the widget is. Console image entries are delivered as image attachments; pass `image_options.max_dimension` (longest edge, default 1568, min 64) to cap resolution and token cost.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"instance_id":{"type":"string"},"widget":{"type":"string"},"image_options":{"type":"object","properties":{"max_dimension":{"type":"integer","minimum":64,"default":1568,"description":"Longest edge in pixels. Larger images are scaled down before being returned as attachments."}},"additionalProperties":false}},"required":["session_id","instance_id","widget"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, parseSessionID(invocation.args) != nil else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let instanceIDStr = invocation.args["instance_id"] as? String,
let instanceID = UUID(uuidString: instanceIDStr)
else {
return errorResult("missing or invalid instance_id", code: .invalidInput)
}
guard let widgetID = invocation.args["widget"] as? String, !widgetID.isEmpty else {
return errorResult("missing widget", code: .invalidInput)
}
let maxDimension = parseImageMaxDimension(invocation.args["image_options"])
let state = engine.widgetState(instanceID: instanceID, widget: widgetID)
let (payload, attachments) = widgetStatePayload(
state,
imageProcessor: engine.imageProcessor,
maxDimension: maxDimension
)
return makeResult(
jsonObject: payload,
attachments: attachments,
summary: "Read state of widget \(widgetID)"
)
}
}
private static let defaultImageMaxDimension = 1568
private static func parseImageMaxDimension(_ raw: Any?) -> Int {
guard let obj = raw as? [String: Any],
let value = obj["max_dimension"] as? Int,
value >= 64
else { return defaultImageMaxDimension }
return value
}
private static func widgetStatePayload(
_ state: WidgetState,
imageProcessor: ImageProcessor?,
maxDimension: Int
) -> (payload: [String: Any], attachments: [LLMAttachment]) {
var points: [[String: Any]] = []
for (seriesID, seriesPoints) in state.graphSeries {
for point in seriesPoints {
points.append(["series": seriesID, "x": point.x, "y": point.y])
}
}
let items = state.listItems.map { item -> [String: Any] in
var obj: [String: Any] = ["id": item.id, "title": item.title]
if let s = item.subtitle { obj["subtitle"] = s }
if let a = item.accessory { obj["accessory"] = a }
return obj
}
let rows = state.tableRows.map { row -> [String: Any] in
["id": row.id, "cells": row.cells]
}
let buckets = state.histogram.map { ["label": $0.label, "count": $0.count] }
var attachments: [LLMAttachment] = []
let consoleEntries = state.consoleEntries.map { entry -> [String: Any] in
consoleEntryPayload(
entry,
imageProcessor: imageProcessor,
maxDimension: maxDimension,
attachments: &attachments
)
}
var obj: [String: Any] = [
"points": points,
"items": items,
"rows": rows,
"buckets": buckets,
"entries": consoleEntries,
]
if let counter = state.counter {
var counterObj: [String: Any] = ["value": counter.value]
if let unit = counter.unit { counterObj["unit"] = unit }
if let delta = counter.delta { counterObj["delta"] = delta }
obj["counter"] = counterObj
}
if let hex = state.hex {
obj["hex"] = [
"bytes": hex.bytes.base64EncodedString(),
"base_address": String(format: "0x%llx", hex.baseAddress),
]
}
return (obj, attachments)
}
private static func consoleEntryPayload(
_ entry: WidgetConsoleEntry,
imageProcessor: ImageProcessor?,
maxDimension: Int,
attachments: inout [LLMAttachment]
) -> [String: Any] {
var obj: [String: Any] = [
"id": entry.id,
"kind": entry.kind.rawValue,
"text": entry.text,
]
if let replyTo = entry.replyTo { obj["reply_to"] = replyTo }
if let image = entry.image {
let attachmentID = "attachment-\(attachments.count + 1)"
let processed = imageProcessor?.scaleDown(
data: image.data,
mediaType: image.mediaType,
maxDimension: maxDimension
)
let delivered = processed ?? ProcessedImage(
mediaType: image.mediaType,
data: image.data,
width: image.width,
height: image.height
)
attachments.append(LLMAttachment(
kind: .image,
mediaType: delivered.mediaType,
base64: delivered.data.base64EncodedString()
))
obj["image"] = [
"attachment_id": attachmentID,
"media_type": delivered.mediaType,
"width": delivered.width,
"height": delivered.height,
"original_width": image.width,
"original_height": image.height,
]
}
return obj
}
private static func registerInvokeWidgetAction(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "invoke_widget_action",
description: "Trigger a widget action (declared on a list or table widget). The instrument's onAction handler runs server-side. Use to programmatically click an action button on a row your instrument has surfaced.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"instance_id":{"type":"string"},"widget":{"type":"string"},"action":{"type":"string"},"item":{"type":"string","description":"Optional row/item id the action targets"}},"required":["session_id","instance_id","widget","action"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let instanceIDStr = invocation.args["instance_id"] as? String,
let instanceID = UUID(uuidString: instanceIDStr)
else {
return errorResult("missing or invalid instance_id", code: .invalidInput)
}
guard let widget = invocation.args["widget"] as? String, !widget.isEmpty,
let action = invocation.args["action"] as? String, !action.isEmpty
else {
return errorResult("missing widget or action", code: .invalidInput)
}
let instance: InstrumentInstance
switch resolveLiveInstrument(engine: engine, sessionID: sessionID, instanceID: instanceID) {
case .found(let live): instance = live.instance
case .error(let err): return err
}
let item = invocation.args["item"] as? String
await engine.invokeWidgetAction(instance: instance, widget: widget, action: action, item: item)
var payload: [String: Any] = [
"instance_id": instanceID.uuidString,
"widget": widget,
"action": action,
]
if let item { payload["item"] = item }
return makeResult(jsonObject: payload, summary: "Invoked \(action) on \(widget)")
}
}
private static func registerSubmitConsoleInput(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "submit_console_input",
description: "Send a line of input to a console widget and wait for the instrument's responses (entries posted with `respond.output` / `respond.error` / `respond.value` from `onConsoleInput`). Returns whatever responses arrived within `timeout_ms` (default 2000). Free-form output the instrument emits via appendOutput / appendError / appendValue is NOT included — only entries explicitly tied back to this input.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"instance_id":{"type":"string"},"widget":{"type":"string"},"text":{"type":"string"},"timeout_ms":{"type":"integer","minimum":1,"maximum":60000,"default":2000}},"required":["session_id","instance_id","widget","text"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let instanceIDStr = invocation.args["instance_id"] as? String,
let instanceID = UUID(uuidString: instanceIDStr)
else {
return errorResult("missing or invalid instance_id", code: .invalidInput)
}
guard let widget = invocation.args["widget"] as? String, !widget.isEmpty else {
return errorResult("missing widget", code: .invalidInput)
}
guard let text = invocation.args["text"] as? String else {
return errorResult("missing text", code: .invalidInput)
}
let instance: InstrumentInstance
switch resolveLiveInstrument(engine: engine, sessionID: sessionID, instanceID: instanceID) {
case .found(let live): instance = live.instance
case .error(let err): return err
}
let timeoutMs = (invocation.args["timeout_ms"] as? Int) ?? 2000
let response = await engine.submitConsoleInputAndAwait(
instance: instance,
widget: widget,
text: text,
timeout: .milliseconds(timeoutMs)
)
let replies = response.replies.map(consoleEntryJSON)
let payload: [String: Any] = [
"instance_id": instanceID.uuidString,
"widget": widget,
"input_entry_id": response.inputEntryID,
"replies": replies,
]
return makeResult(jsonObject: payload, summary: "Submitted to \(widget), \(replies.count) repl\(replies.count == 1 ? "y" : "ies")")
}
}
private static func consoleEntryJSON(_ entry: WidgetConsoleEntry) -> [String: Any] {
entry.toWireJSON()
}
private struct LiveInstrument {
let instance: InstrumentInstance
let node: ProcessNode
let ref: ProcessNode.InstrumentRef
}
private enum LiveInstrumentLookup {
case found(LiveInstrument)
case error(ActionResult)
}
private static func resolveLiveInstrument(
engine: Engine,
sessionID: UUID,
instanceID: UUID
) -> LiveInstrumentLookup {
guard let session = engine.sessions.first(where: { $0.id == sessionID }) else {
return .error(errorResult("no session with id \(sessionID)", code: .notFound))
}
guard let node = engine.node(forSessionID: sessionID) else {
return .error(errorResult(
"session \(sessionID) is not attached (phase=\(phaseDescription(session.phase)))",
code: .unavailable
))
}
guard let instance = engine.instrumentsBySession[sessionID]?.first(where: { $0.id == instanceID }) else {
return .error(errorResult("no instrument \(instanceID) on session \(sessionID)", code: .notFound))
}
guard let ref = node.instruments.first(where: { $0.id == instanceID }), ref.attachment == .attached else {
return .error(errorResult(
"instrument \(instanceID) is not attached on session \(sessionID) — recompile may have failed",
code: .unavailable
))
}
if let status = ref.status {
return .error(errorResult(
"instrument \(instanceID) is in error state: \(status.summary)",
code: .unavailable
))
}
return .found(LiveInstrument(instance: instance, node: node, ref: ref))
}
private static func registerDetachSession(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "detach_session",
description: "Drop a session entirely: detach Frida, dispose any attached instruments, and remove the session record from the project. Use to clean up sessions you created when a mission is finishing. Requires user approval.",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"}},"required":["session_id"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard engine.sessions.contains(where: { $0.id == sessionID }) else {
return errorResult("no session with id \(sessionID)", code: .notFound)
}
engine.deleteSession(id: sessionID)
let payload: [String: Any] = ["session_id": sessionID.uuidString, "removed": true]
return makeResult(jsonObject: payload, summary: "Detached session \(sessionID.uuidString.prefix(8))")
}
}
private static func addressInsightJSON(_ insight: AddressInsight, engine: Engine) -> [String: Any] {
var obj: [String: Any] = [
"id": insight.id.uuidString,
"title": engine.displayTitle(for: insight),
"kind": insight.kind == .disassembly ? "disassembly" : "memory",
"anchor": insight.anchor.displayString,
"byte_count": insight.byteCount,
"created_at": ISO8601DateFormatter().string(from: insight.createdAt),
]
if let addr = insight.lastResolvedAddress {
obj["last_resolved_address"] = String(format: "0x%llx", addr)
}
if !insight.placements.isEmpty {
obj["placements"] = insight.placements.map { placement -> [String: Any] in
["id": placement.id.uuidString, "pattern_id": placement.sourceID, "type": placement.typeName, "offset": placement.offset]
}
}
return obj
}
private static func registerRequestUserInput(in catalog: ToolCatalog) {
let spec = ActionSpec(
name: requestUserInputToolName,
description: "Pause the mission and ask the user a clarifying question. The user's text answer becomes the tool result. Optionally provide a small list of suggested options.",
inputSchemaJSON: """
{"type":"object","properties":{"question":{"type":"string","description":"The question to ask the user"},"options":{"type":"array","items":{"type":"string"},"description":"Optional short list of suggested answers"}},"required":["question"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false
)
catalog.register(spec: spec) { _ in
errorResult("request_user_input must be answered via the Action Queue, not approved directly", code: .rejected)
}
}
private static func lineCount(of content: String) -> Int {
if content.isEmpty { return 0 }
var count = content.reduce(0) { $1 == "\n" ? $0 + 1 : $0 }
if !content.hasSuffix("\n") { count += 1 }
return count
}
private static func numberedContent(_ content: String) -> String {
if content.isEmpty { return "" }
var lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
if content.hasSuffix("\n") {
lines.removeLast()
}
let width = max(4, String(lines.count).count)
var result = ""
result.reserveCapacity(content.count + lines.count * (width + 2))
for (index, line) in lines.enumerated() {
let n = String(index + 1)
let pad = String(repeating: " ", count: width - n.count)
result += pad + n + "\t" + line + "\n"
}
return result
}
private enum SpliceOutcome {
case success(String)
case failure(String)
}
private static func spliceLines(in content: String, startLine: Int, endLine: Int, replacement: String) -> SpliceOutcome {
let hasTrailingNewline = content.hasSuffix("\n")
var lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
if hasTrailingNewline, !lines.isEmpty {
lines.removeLast()
}
let total = lines.count
if startLine < 1 || startLine > total + 1 {
return .failure("start_line \(startLine) out of range (file has \(total) line\(total == 1 ? "" : "s"))")
}
if endLine < startLine - 1 || endLine > total {
return .failure("end_line \(endLine) out of range for start_line \(startLine) (file has \(total) line\(total == 1 ? "" : "s"))")
}
var insertion = replacement.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
if replacement.hasSuffix("\n"), !insertion.isEmpty {
insertion.removeLast()
}
lines.replaceSubrange((startLine - 1)..<endLine, with: insertion)
let joined = lines.joined(separator: "\n")
let needsTrailingNewline = hasTrailingNewline || !replacement.isEmpty
return .success(needsTrailingNewline && !joined.hasSuffix("\n") ? joined + "\n" : joined)
}
static func parseSessionID(_ args: [String: Any]) -> UUID? {
guard let str = args["session_id"] as? String else { return nil }
return UUID(uuidString: str)
}
private static func parseProcessScope(_ raw: String?) -> Scope {
switch raw {
case "metadata": return .metadata
case "full": return .full
default: return .minimal
}
}
static func parseHexAddress(_ s: String) -> UInt64? {
let trimmed = s.lowercased()
if trimmed.hasPrefix("0x") {
return UInt64(trimmed.dropFirst(2), radix: 16)
}
return UInt64(trimmed)
}
private static func parseHexBytes(_ s: String) -> [UInt8]? {
let trimmed = s.lowercased().hasPrefix("0x") ? String(s.dropFirst(2)) : s
guard trimmed.count % 2 == 0 else { return nil }
var result: [UInt8] = []
result.reserveCapacity(trimmed.count / 2)
var index = trimmed.startIndex
while index < trimmed.endIndex {
let next = trimmed.index(index, offsetBy: 2)
guard let byte = UInt8(trimmed[index..<next], radix: 16) else { return nil }
result.append(byte)
index = next
}
return result
}
static func makeResult(
jsonObject: Any,
attachments: [LLMAttachment] = [],
summary: String
) -> ActionResult {
let scrubbed = truncatingOversizedStrings(jsonObject, cap: stringValueByteCap)
guard JSONSerialization.isValidJSONObject(scrubbed) else {
let offender = describeNonJSONValue(scrubbed) ?? "unknown"
return errorResult("internal: tool produced non-JSON value (\(offender))")
}
let data = (try? JSONSerialization.data(withJSONObject: scrubbed, options: [.sortedKeys])) ?? Data("{}".utf8)
var json = String(data: data, encoding: .utf8) ?? "{}"
if json.utf8.count > resultByteCap {
json = String(json.prefix(resultByteCap))
json += "\n/* truncated — request a narrower view */"
}
return ActionResult(summary: summary, resultJSON: json, attachments: attachments)
}
private static func describeNonJSONValue(_ value: Any, path: String = "$") -> String? {
if let dict = value as? [String: Any] {
for (k, v) in dict {
if let hit = describeNonJSONValue(v, path: "\(path).\(k)") {
return hit
}
}
return nil
}
if let arr = value as? [Any] {
for (i, v) in arr.enumerated() {
if let hit = describeNonJSONValue(v, path: "\(path)[\(i)]") {
return hit
}
}
return nil
}
if JSONSerialization.isValidJSONObject([value]) { return nil }
return "\(path)=<\(type(of: value))>"
}
private static func truncatingOversizedStrings(_ value: Any, cap: Int) -> Any {
if let str = value as? String {
let byteCount = str.utf8.count
if byteCount <= cap { return str }
let head = String(decoding: str.utf8.prefix(cap), as: UTF8.self)
return "\(head)… <truncated; original \(byteCount) bytes>"
}
if let dict = value as? [String: Any] {
return dict.mapValues { truncatingOversizedStrings($0, cap: cap) }
}
if let arr = value as? [Any] {
return arr.map { truncatingOversizedStrings($0, cap: cap) }
}
return value
}
static func errorResult(_ message: String, code: ToolErrorCode = .failed) -> ActionResult {
let json = "{\"error\":\"\(escapeJSON(message))\",\"code\":\"\(code.rawValue)\"}"
return ActionResult(summary: message, resultJSON: json, isError: true)
}
enum ToolErrorCode: String {
case invalidInput = "invalid_input"
case notFound = "not_found"
case unavailable = "unavailable"
case rejected = "rejected"
case failed = "failed"
}
private enum LLMQueryOutcome {
case success(String)
case failure(String)
}
private static func runLLMQuery(
engine: Engine,
providerID: String,
modelID: String,
system: String,
user: String,
maxOutputTokens: Int = 1024,
temperature: Double = 0.2
) async -> LLMQueryOutcome {
guard let provider = engine.llmRegistry.provider(id: providerID) else {
return .failure("provider \(providerID) not registered")
}
let apiKey = (try? await engine.llmCredentials.apiKey(providerID: providerID)) ?? nil
if provider.descriptor.capabilities.supports(.apiKey), apiKey == nil {
return .failure("missing API key for provider \(providerID)")
}
let request = LLMTurnRequest(
modelID: modelID,
systemBlocks: [LLMContentBlock(content: .text(system), cacheBoundary: true)],
messages: [LLMMessage(role: .user, blocks: [.text(user)])],
tools: [],
maxOutputTokens: maxOutputTokens,
thinkingBudget: 0,
temperature: temperature
)
var output = ""
do {
let baseURL = LumaAppState.shared.providerBaseURL(providerID: providerID).flatMap(URL.init(string:))
for try await event in provider.streamTurn(request, apiKey: apiKey, baseURL: baseURL) {
if case .finalMessage(_, let blocks) = event {
for block in blocks {
if case .text(let t) = block.content {
output += t
}
}
}
}
} catch {
return .failure(error.localizedDescription)
}
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return .failure("model returned empty response")
}
return .success(trimmed)
}
private static func describeEventKind(_ event: RuntimeEvent) -> String {
switch event.source {
case .processOutput: return "process_output"
case .script: return "script"
case .console: return "console"
case .repl: return "repl"
case .instrument(_, let name): return "instrument:\(name)"
case .spawnGating: return "spawn_gating"
case .engine(let subsystem): return "engine:\(subsystem)"
}
}
private static func describeEventSummary(_ event: RuntimeEvent) -> String {
switch event.payload {
case .consoleMessage(let msg):
return msg.description
case .jsError(let err):
return err.text
case .jsValue(let value):
if isTracerEvent(event), let tracer = Engine.parseTracerEvent(from: value) {
return tracer.message.agentSummary()
}
return value.agentSummary()
case .raw:
return "[raw payload]"
}
}
private static func escapeJSON(_ s: String) -> String {
var out = ""
for c in s {
switch c {
case "\"": out.append("\\\"")
case "\\": out.append("\\\\")
case "\n": out.append("\\n")
case "\r": out.append("\\r")
case "\t": out.append("\\t")
default: out.append(c)
}
}
return out
}
}