Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Foundation
import os

/// The app's ``AgentApprovalAnswering``: each read or answer is one short person-proven
/// connection to this app's acpmux (``AcpmuxPersonLine``). An answer re-reads the prompt and
/// counts only while the same request (``AcpmuxPermissionPrompt/digest(_:)``) is pending and
/// offers the option the decision maps to (``AcpmuxPermissionPrompt/option(allow:offered:)``,
/// the feed bridge's own mapping). Then `_acpmux/permission_respond`, as the chat's own Allow
/// and Deny send it.
@MainActor
public final class AcpmuxApprovalAnswerer: AgentApprovalAnswering {
private let environment: AcpmuxEnvironment
private let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "notifications.approval")

public init(environment: AcpmuxEnvironment) {
self.environment = environment
}

public func pendingApproval(session: String) async -> AgentApproval? {
guard let line = await AcpmuxPersonLine.open(environment) else { return nil }
defer { line.close() }
let pending = await line.pending(of: session)
// One prompt only: with several, a banner could not say which one its buttons answer.
guard pending.count == 1, let permission = pending[0]["permissionId"] as? String,
let request = pending[0]["request"] as? [String: Any], AcpmuxPermissionPrompt.isToolPermission(request)
else { return nil }
return AgentApproval(session: session, permission: permission, digest: AcpmuxPermissionPrompt.digest(request),
summary: AcpmuxPermissionPrompt.summary(request))
}

public func answer(_ approval: AgentApproval, allow: Bool) async -> Bool {
guard let line = await AcpmuxPersonLine.open(environment) else { return false }
defer { line.close() }
// acpmux refuses an allow from a connection that is not the person.
if allow, !line.isPerson {
logger.error("approval banner: acpmux refused this app's person proof; nothing allowed")
return false
}
guard let now = await line.pending(of: approval.session).first(where: { $0["permissionId"] as? String == approval.permission }),
let request = now["request"] as? [String: Any], AcpmuxPermissionPrompt.digest(request) == approval.digest,
let option = AcpmuxPermissionPrompt.option(allow: allow, offered: request["options"] as? [[String: Any]] ?? [])
else {
logger.info("approval banner: the prompt ended or changed since its banner posted; nothing answered")
return false
}
let params: [String: Any] = ["sessionId": approval.session, "permissionId": approval.permission, "optionId": option]
return await line.call("_acpmux/permission_respond", params) != nil
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Foundation
import CryptoKit

/// An acpmux permission prompt as the app reads and answers it for the person (cx-aocz, cx-kz6m0.2):
/// the feed bridge (an answer from the phone) and approval banners share this. Allow maps to the
/// offered `allow_once` option, deny to the offered reject option; an option kind offered twice maps
/// to nothing. Shown text is scrubbed of secret shapes and shows invisible characters.
nonisolated enum AcpmuxPermissionPrompt {
/// One JSON-RPC request line.
static func frame(id: Int, method: String, params: [String: Any]) -> Data {
let request: [String: Any] = ["jsonrpc": "2.0", "id": id, "method": method, "params": params]
var data = (try? JSONSerialization.data(withJSONObject: request)) ?? Data()
data.append(0x0A)
return data
}

/// sha256 of the request's canonical JSON (sorted keys).
static func digest(_ request: [String: Any]) -> String {
let data = (try? JSONSerialization.data(withJSONObject: request, options: [.sortedKeys, .withoutEscapingSlashes])) ?? Data()
return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}

/// The command a tool input runs: a string, or an argv array joined as a shell would read it
/// (`["bash", "-lc", "rm -rf ~"]` shows as `bash -lc 'rm -rf ~'`). An array with other values
/// shows as compact JSON.
static func shownCommand(_ value: Any?) -> String? {
if let text = value as? String { return text }
guard let parts = value as? [Any] else { return value.map(compactJSON) }
guard let words = parts as? [String] else { return compactJSON(parts) }
return words.map(shellQuoted).joined(separator: " ")
}

static func shellQuoted(_ word: String) -> String {
let plain = !word.isEmpty && word.unicodeScalars.allSatisfy {
CharacterSet.alphanumerics.contains($0) && $0.isASCII || "-_./=:,+@%".unicodeScalars.contains($0)
}
return plain ? word : "'" + word.replacingOccurrences(of: "'", with: "'\\''") + "'"
}

static func compactJSON(_ value: Any) -> String {
let data = (try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys, .withoutEscapingSlashes, .fragmentsAllowed])) ?? Data()
return String(decoding: data, as: UTF8.self)
}

/// Bidi controls and zero-width characters shown as `<U+XXXX>`, so a command cannot read
/// one way and run another.
static func visible(_ text: String) -> String {
var out = ""
for scalar in text.unicodeScalars {
switch scalar.value {
case 0x061C, 0x180E, 0x200B...0x200F, 0x202A...0x202E, 0x2060...0x2064, 0x2066...0x2069, 0xFEFF:
out += String(format: "<U+%04X>", scalar.value)
default:
out.unicodeScalars.append(scalar)
}
}
return out
}

/// A short, scrubbed summary of a tool's input besides its command: field names with their
/// values (paths, patterns; other values as compact JSON), `cwd` first whenever present.
/// `env` is never shown, and its presence marks the summary truncated. `truncated`: a field
/// beyond the first 8, a long name, a long value, or `env` (the phone then never allows).
static func inputSummary(_ input: [String: Any]) -> (fields: [String: String], truncated: Bool)? {
var out: [String: String] = [:]
let env = input.first { $0.key.lowercased() == "env" }
var truncated = env.map { !(($0.value as? [String: Any])?.isEmpty ?? false) } ?? false
let shown = input.filter { $0.key.lowercased() != "env" && $0.key != "command" }
.sorted { ($0.key == "cwd" ? 0 : 1, $0.key) < ($1.key == "cwd" ? 0 : 1, $1.key) }
if shown.count > 8 { truncated = true }
for (key, value) in shown.prefix(8) {
let raw = value as? String ?? compactJSON(value)
let scrubbed = FeedSecretScrubber.scrub(raw)
// A redaction must never hide what the person signs.
if scrubbed != raw { truncated = true }
let text = visible(scrubbed)
if key.count > 40 || text.count > 200 { truncated = true }
out[cut(visible(key), 40)] = cut(text, 200)
}
// Two shown names that read the same after the cut collide: one value is not shown.
if out.count < min(shown.count, 8) { truncated = true }
return out.isEmpty && !truncated ? nil : (out, truncated)
}

static func cut(_ text: String, _ limit: Int) -> String {
text.count > limit ? String(text.prefix(limit - 1)) + "…" : text
}

static func option(allow: Bool, offered: [[String: Any]]) -> String? {
let kinds = allow ? ["allow_once"] : ["reject_once", "reject_always"]
for kind in kinds {
let matches = offered.filter { $0["kind"] as? String == kind }
if matches.count == 1, let id = matches[0]["optionId"] as? String { return id }
}
return nil
}

/// A tool permission both buttons can answer: not a question (the daemon's normalized
/// `toolCall._meta.acpmux.question`), and it offers allow once and a deny.
static func isToolPermission(_ request: [String: Any]) -> Bool {
let tool = request["toolCall"] as? [String: Any]
let acpmux = (tool?["_meta"] as? [String: Any])?["acpmux"] as? [String: Any]
let offered = request["options"] as? [[String: Any]] ?? []
return acpmux?["question"] == nil && option(allow: true, offered: offered) != nil
&& option(allow: false, offered: offered) != nil
}

/// What the prompt asks to do, for a banner: its tool call's title, then the command it runs,
/// secrets scrubbed and invisible characters shown (as the feed bridge shows them).
static func summary(_ request: [String: Any]) -> String {
let call = request["toolCall"] as? [String: Any] ?? [:]
let input = call["rawInput"] as? [String: Any] ?? [:]
let lines = [call["title"] as? String, shownCommand(input["command"])]
.compactMap { $0.map { visible(FeedSecretScrubber.scrub($0)) } }
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let unique = lines.count == 2 && lines[0] == lines[1] ? [lines[0]] : lines
return cut(unique.joined(separator: "\n"), 300)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import CmuxNextWakeups
import Foundation

/// One short connection to this app's acpmux for a few requests, proven as the person the way
/// the feed bridge's connection is (acpmux `hub/person.rs`: `initialize`, then
/// `_acpmux/person_prove` for its challenge as the second request). The server peer is checked
/// before the first byte (``AcpmuxCheckedLine``). The whole connection lives at most `deadline`;
/// every request still waiting then answers nil.
@MainActor
final class AcpmuxPersonLine {
private let line: AcpmuxCheckedLine
private let timer = DemandTimer(owner: "acpmux.person-line")
private var nextID = 10
private var waiters: [Int: CheckedContinuation<Data?, Never>] = [:]
private var closed = false
/// acpmux took the person proof: this connection may allow.
private(set) var isPerson = false

private init(environment: AcpmuxEnvironment) {
line = AcpmuxCheckedLine(environment: environment)
}

/// Connects, initializes and proves the person; nil when no acpmux answers. A connection
/// whose proof was refused still reads and denies (``isPerson`` is false).
static func open(_ environment: AcpmuxEnvironment, deadline: Duration = .seconds(5)) async -> AcpmuxPersonLine? {
guard FileManager.default.fileExists(atPath: environment.socketPath) else { return nil }
let connection = AcpmuxPersonLine(environment: environment)
connection.timer.schedule(after: deadline) { @MainActor [weak connection] in connection?.close() }
guard let initialized = await connection.start() else {
connection.close()
return nil
}
let challenge = ((initialized["_meta"] as? [String: Any])?["acpmux"] as? [String: Any])?["personChallenge"] as? [String: Any]
if let nonce = challenge?["nonce"] as? String, let id = challenge?["connection"] as? String,
let proof = connection.line.personProof(nonce: nonce, connection: id) {
connection.isPerson = (await connection.call("_acpmux/person_prove", ["proof": proof]))?["person"] as? Bool ?? false
}
return connection
}

/// One request; its `result`, or nil when it failed or the connection closed.
func call(_ method: String, _ params: [String: Any]) async -> [String: Any]? {
guard !closed else { return nil }
nextID += 1
let id = nextID
let frame = AcpmuxPermissionPrompt.frame(id: id, method: method, params: params)
let reply: Data? = await withCheckedContinuation { waiter in
waiters[id] = waiter
line.send(frame)
}
return reply.flatMap { (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] }
}

/// The pending prompts of `session` (`_acpmux/info`).
func pending(of session: String) async -> [[String: Any]] {
(await call("_acpmux/info", ["sessionId": session]))?["pending"] as? [[String: Any]] ?? []
}

func close() {
guard !closed else { return }
closed = true
timer.cancel()
line.cancel()
for (_, waiter) in waiters { waiter.resume(returning: nil) }
waiters = [:]
}

/// Sends `initialize` as the first request and waits for its result.
private func start() async -> [String: Any]? {
let frame = AcpmuxPermissionPrompt.frame(id: 1, method: "initialize", params: [
"protocolVersion": 1, "clientInfo": ["name": "cmux-next-approval-banner", "version": "1"],
"clientCapabilities": [String: Any](),
])
let reply: Data? = await withCheckedContinuation { waiter in
waiters[1] = waiter
line.start(send: frame,
onLine: { [weak self] data in Task { @MainActor in self?.handle(data) } },
onClose: { [weak self] in Task { @MainActor in self?.close() } })
}
return reply.flatMap { (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] }
}

private func handle(_ data: Data) {
guard let message = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let id = (message["id"] as? NSNumber)?.intValue, let waiter = waiters.removeValue(forKey: id) else { return }
let result = message["error"] == nil
? try? JSONSerialization.data(withJSONObject: message["result"] as? [String: Any] ?? [:]) : nil
waiter.resume(returning: result)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
public import Foundation

/// An acpmux permission request (`request` of `_acpmux/permission_pending`) as the app reads it
/// for the person: the feed bridge and approval banners share these (``AcpmuxPermissionPrompt``).
public extension Dictionary where Key == String, Value == Any {
/// sha256 of the request's canonical JSON (sorted keys): an answer counts only for the same one.
var acpmuxDigest: String { AcpmuxPermissionPrompt.digest(self) }
/// A tool input's `command` as a shell would read it (an argv array shell-quoted).
var acpmuxShownCommand: String? { AcpmuxPermissionPrompt.shownCommand(self["command"]) }
/// A tool input's other fields, scrubbed and cut, and whether anything was cut or dropped.
var acpmuxInputSummary: (fields: [String: String], truncated: Bool)? { AcpmuxPermissionPrompt.inputSummary(self) }
}

public extension Array where Element == [String: Any] {
/// The option of these offered ones an allow once (`allow`) or a deny maps to; nil when not
/// offered exactly once.
func acpmuxOption(allow: Bool) -> String? { AcpmuxPermissionPrompt.option(allow: allow, offered: self) }
}

public extension String {
/// Bidi controls and zero-width characters shown as `<U+XXXX>`.
var acpmuxVisible: String { AcpmuxPermissionPrompt.visible(self) }
/// At most `limit` characters, the last one an ellipsis when cut.
func acpmuxCut(_ limit: Int) -> String { AcpmuxPermissionPrompt.cut(self, limit) }
/// Known secret shapes replaced with `[redacted]` (``FeedSecretScrubber``).
var secretsScrubbed: String { FeedSecretScrubber.scrub(self) }
/// ``secretsScrubbed`` of the first 16 KiB characters (unbounded program output).
var secretsScrubbedPrefix: String { FeedSecretScrubber.scrubPrefix(self) }
}

public extension Data {
/// One JSON-RPC request line to acpmux.
init(acpmuxRequest id: Int, method: String, params: [String: Any]) {
self = AcpmuxPermissionPrompt.frame(id: id, method: method, params: params)
}
}
Loading
Loading