Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ public nonisolated final class AcpmuxSecretKey: @unchecked Sendable { // crash-a
}
}

/// The key of 64 lowercase hex digits (tests: a key acpmux's own tests use too).
init?(hex: String) {
// Parsed before the buffer exists, so a bad digit fails with nothing to free.
let hexDigits = Set("0123456789abcdef".utf8)
let digits = Array(hex.utf8)
guard digits.count == Self.length * 2, digits.allSatisfy(hexDigits.contains) else { return nil }
let parsed = (0..<Self.length).compactMap {
UInt8(String(decoding: digits[($0 * 2)...($0 * 2 + 1)], as: UTF8.self), radix: 16)
}
guard parsed.count == Self.length else { return nil }
bytes = UnsafeMutableRawBufferPointer.allocate(byteCount: Self.length, alignment: 1)
bytes.copyBytes(from: parsed)
}

deinit {
Self.zero(bytes)
bytes.deallocate()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import CommonCrypto
import Foundation

/// The decision proof of a relayed answer to a question (acpmux `decision_answers_proof`,
/// `hub/person/decision_answers.rs`): an AskUserQuestion answered in this pane for a chat on
/// another machine. The allow's proof also covers the answers, so that machine's daemon counts
/// the answer as the person's and a relay cannot swap them. Without it every such answer was
/// refused as "accepted only from the cmux app" (Leo, 2026-10-21).
extension AcpmuxSecretKey {
/// `personDecision` of a relayed answer; nil when the answers have no canonical form (a
/// fractional number, which no question answer has).
public func decisionProof(instance: String, session: String, permission: String, option: String, nonce: String,
answers: Any) -> String? {
guard let canonical = Self.canonicalJSON(answers) else { return nil }
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
let text = Array(canonical.utf8)
text.withUnsafeBytes { _ = CC_SHA256($0.baseAddress, CC_LONG(text.count), &digest) }
let hex = digest.map { String(format: "%02x", $0) }.joined()
return mac(domain: "acpmux-person-decision-answers-v1", parts: [instance, session, permission, option, nonce, hex])
}

/// The bytes acpmux hashes (`approvers::canonical_json`): object keys in UTF-8 byte order, no
/// whitespace, and only `"`, `\` and control characters escaped (`\b \f \n \r \t`, else
/// `\u00xx`). `value` is what `JSONSerialization` parsed.
static func canonicalJSON(_ value: Any) -> String? {
switch value {
case is NSNull:
return "null"
case let string as String:
return canonicalString(string)
case let number as NSNumber:
if CFGetTypeID(number) == CFBooleanGetTypeID() { return number.boolValue ? "true" : "false" }
if CFNumberIsFloatType(number) {
let double = number.doubleValue
guard double.rounded() == double, abs(double) < 1e15 else { return nil }
return String(Int64(double))
}
return number.stringValue
case let array as [Any]:
var parts: [String] = []
for item in array {
guard let part = canonicalJSON(item) else { return nil }
parts.append(part)
}
return "[" + parts.joined(separator: ",") + "]"
case let object as [String: Any]:
var parts: [String] = []
for key in object.keys.sorted(by: { Array($0.utf8).lexicographicallyPrecedes(Array($1.utf8)) }) {
guard let item = object[key], let part = canonicalJSON(item) else { return nil }
parts.append(canonicalString(key) + ":" + part)
}
return "{" + parts.joined(separator: ",") + "}"
default:
return nil
}
}

private static func canonicalString(_ string: String) -> String {
var out = "\""
for scalar in string.unicodeScalars {
switch scalar {
case "\"": out += "\\\""
case "\\": out += "\\\\"
case "\u{8}": out += "\\b"
case "\u{c}": out += "\\f"
case "\n": out += "\\n"
case "\r": out += "\\r"
case "\t": out += "\\t"
case _ where scalar.value < 0x20: out += String(format: "\\u%04x", scalar.value)
default: out.unicodeScalars.append(scalar)
}
}
return out + "\""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,11 @@ public nonisolated struct AgentPaneGesturePick: Equatable, Sendable {
self.params = (acpmux?["promptId"] as? String).map { ["promptId": .string($0)] }
return
}
// A permission answer's pick names its session too (the ticket binds the prompt).
// A permission answer's pick names its session too (the ticket binds the prompt). A
// question's `answers` are no pick: the answers rule bounds them and the proof covers them.
if method == "_acpmux/permission_respond" {
guard let session = params["sessionId"] as? String, let permission = params["permissionId"] as? String,
let option = params["optionId"] as? String, params["answers"] == nil else {
let option = params["optionId"] as? String else {
self.params = nil
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,26 @@ extension AgentPaneTransport {
Self.logger.info("agent pane transport relayed allow without the person's ticket: sent without a proof")
return
}
// A question's answers: the proof covers them too, else that daemon refuses the answer.
if let answers = Self.answers(box) {
guard let proof = key.decisionProof(instance: context.instance, session: session, permission: permission,
option: option, nonce: context.nonce, answers: answers) else {
Self.logger.info("agent pane transport relayed answer without a canonical form: sent without a proof")
return
}
box.setDecision(nonce: context.nonce, proof: proof)
return
}
let proof = key.decisionProof(instance: context.instance, session: session, permission: permission, option: option,
nonce: context.nonce)
box.setDecision(nonce: context.nonce, proof: proof)
}

/// The frame's `answers` (a question's), when it has any that are not null.
private static func answers(_ box: FrameBox) -> Any? {
guard let text = box.encoded(id: nil),
let object = (try? JSONSerialization.jsonObject(with: Data(text.utf8))) as? [String: Any],
let answers = (object["params"] as? [String: Any])?["answers"], !(answers is NSNull) else { return nil }
return answers
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ public nonisolated protocol AgentSessionRemoteClient: Sendable {
/// Sends one text prompt; answers `{prompt_id, turn_id, queued}` once acpmux recorded it.
func prompt(id: String, text: String) async throws -> Data
func cancel() async throws
/// Answers a permission request the daemon announced on this attachment, with the person's
/// decision proof of a relayed allow when the pane made one (cx-0q7o; acpmux decides).
func permission(id: String, option: String, decisionNonce: String?, personDecision: String?) async throws
/// Answers a permission request the daemon announced on this attachment, with a question's
/// `answers` (JSON) and the person's decision proof of a relayed allow when the pane made one
/// (cx-0q7o; acpmux decides, and an answer's proof covers its answers).
func permission(id: String, option: String, answers: Data?, decisionNonce: String?, personDecision: String?) async throws
func detach() async
/// Ends the attachment and its daemon connection.
func close() async
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,13 @@ public nonisolated final class RemoteAcpmuxWire: AcpmuxPaneWire {
// The decision proof the host added (never the page): acpmux on that machine decides.
let decision = (params["_meta"] as? [String: Any])?["acpmux"] as? [String: Any]
let nonce = decision?["decisionNonce"] as? String, proof = decision?["personDecision"] as? String
// A question's answers go along unchanged: that machine's acpmux checks them and the
// proof over them (dropped, the answer was refused there).
let answers = params["answers"].flatMap { $0 is NSNull ? nil : $0 }
.flatMap { try? JSONSerialization.data(withJSONObject: $0, options: [.fragmentsAllowed]) }
run(id) { client in
try await client.permission(id: permission, option: option, decisionNonce: nonce, personDecision: proof)
try await client.permission(id: permission, option: option, answers: answers, decisionNonce: nonce,
personDecision: proof)
return Data("{}".utf8)
}
default:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,10 @@ nonisolated struct AgentTabRemoteClient: AgentSessionRemoteClient {

func cancel() async throws { _ = try await mapped { try await client.cancel(); return Data() } }

func permission(id: String, option: String, decisionNonce: String?, personDecision: String?) async throws {
func permission(id: String, option: String, answers: Data?, decisionNonce: String?, personDecision: String?) async throws {
_ = try await mapped {
try await client.permission(id: id, option: option, decisionNonce: decisionNonce, personDecision: personDecision)
try await client.permission(id: id, option: option, answers: answers, decisionNonce: decisionNonce,
personDecision: personDecision)
return Data()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,15 +155,17 @@ public actor AgentSessionAttachClient {
_ = try await call("agent-session-cancel", [:])
}

/// `agent-session-permission`, with the person's decision proof of a relayed allow when there
/// is one (cx-0q7o): the daemon passes it to acpmux unchanged, and acpmux decides.
public func permission(id: String, option: String, decisionNonce: String? = nil, personDecision: String? = nil) async throws {
/// `agent-session-permission`, with a question's `answers` (a JSON object) and the person's
/// decision proof of a relayed allow when there is one (cx-0q7o): the daemon passes both to
/// acpmux unchanged, and acpmux decides (an answer's proof covers its answers).
public func permission(id: String, option: String, answers: Data? = nil, decisionNonce: String? = nil,
personDecision: String? = nil) async throws {
var params: [String: any Sendable] = ["permission_id": id, "option_id": option]
if let decisionNonce, let personDecision {
params["decision_nonce"] = decisionNonce
params["person_decision"] = personDecision
}
_ = try await call("agent-session-permission", params)
_ = try await call("agent-session-permission", params, answers: answers)
}

public func detach() async {
Expand Down Expand Up @@ -200,10 +202,14 @@ public actor AgentSessionAttachClient {
return params
}

/// One verb on the tab; the result's `data` as JSON, or the daemon's refusal.
private func call(_ cmd: String, _ params: [String: any Sendable]) async throws -> Data {
/// One verb on the tab; the result's `data` as JSON, or the daemon's refusal. `answers` (a
/// question's, as JSON) goes in the request as it is.
private func call(_ cmd: String, _ params: [String: any Sendable], answers: Data? = nil) async throws -> Data {
let transport = try await liveTransport()
var object = params
var object: [String: Any] = params
if let answers {
object["answers"] = try JSONSerialization.jsonObject(with: answers, options: [.fragmentsAllowed])
}
object["cmd"] = cmd
object["surface"] = surface
// Integers stay integers (the daemon reads u64 seqs and surface ids).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,59 @@ struct AgentPaneRelayedAllowTests {
}
}
}

static func pendingQuestion() -> String {
#"{"jsonrpc":"2.0","method":"_acpmux/permission_pending","params":{"sessionId":"s","permissionId":"p1","decisionNonce":"\#(nonce)","personInstance":"\#(instance)","request":{"options":[{"optionId":"yes","kind":"allow_once"},{"optionId":"no","kind":"reject_once"}],"toolCall":{"_meta":{"acpmux":{"question":{"items":[{"id":"q1","prompt":"Which one?"}]}}}}}}}"#
}

/// Leo 2026-10-21: an agent's question answered in the pane for a chat on another machine was
/// refused there as "accepted only from the cmux app". The answer's proof covers its answers
/// (acpmux `decision_answers_proof`), so that machine counts it as the person's.
@Test func anAnswerToAnotherMachinesQuestionCarriesTheProofOverItsAnswers() async throws {
let machine = "machine-\(UUID().uuidString)"
let key = try #require(AcpmuxSecretKey())
AcpmuxRemotePersonKeys.shared.bind(machine: machine, instance: Self.instance, key: key)
defer { AcpmuxRemotePersonKeys.shared.drop(machine: machine) }
let approvals = AgentPaneRemoteApprovals(machineID: machine, label: "host") { nil }
let wire = RemoteWire()
let route = AgentPaneRemoteRoute(machine: "host", approvals: approvals) { wire }
let inbox = PageInbox()
let transport = AgentPaneTransport()
transport.deliver = { event, done in inbox.deliver(event.frames); done() }
let id = try await transport.open(.remote(route))
_ = await transport.send(connection: id, frames: [#"{"jsonrpc":"2.0","id":0,"method":"initialize","params":{}}"#])
_ = await inbox.frame(containing: "\"origin\":\"remote\"")
transport.sessions.add("s")
wire.push(Self.pendingQuestion())
_ = await inbox.frame(containing: "permission_pending")

transport.gestures.record(click: true)
let ticket = try #require(transport.reserveGesture(try #require(Self.intent(option: "yes"))))
let answer = #"{"jsonrpc":"2.0","id":7,"method":"_acpmux/permission_respond","params":{"sessionId":"s","permissionId":"p1","optionId":"yes","answers":{"Which one?":"B"},"_meta":{"cmuxGesture":"\#(ticket)"}}}"#
let error = await transport.send(connection: id, frames: [answer])
#expect(error == nil, "\(String(describing: error))")
let sent = await wire.sent(containing: "permission_respond")
let object = try JSONSerialization.jsonObject(with: Data(sent.utf8)) as? [String: Any]
let params = try #require(object?["params"] as? [String: Any])
let acpmux = (params["_meta"] as? [String: Any])?["acpmux"] as? [String: Any]
#expect(acpmux?["decisionNonce"] as? String == Self.nonce)
let expected = key.decisionProof(instance: Self.instance, session: "s", permission: "p1", option: "yes", nonce: Self.nonce,
answers: ["Which one?": "B"])
#expect(expected != nil)
#expect(acpmux?["personDecision"] as? String == expected, "the answer went without the proof over its answers: \(sent)")
#expect(acpmux?["personDecision"] as? String
!= key.decisionProof(instance: Self.instance, session: "s", permission: "p1", option: "yes", nonce: Self.nonce))
}

/// The same bytes acpmux checks (`the_answers_decision_proof_matches_the_apps_vector` in
/// cmux-tui/crates/acpmux/tests/person_allow.rs): key order by UTF-8 bytes, acpmux's escapes.
@Test func theAnswersProofMatchesAcpmuxsVector() throws {
let key = try #require(AcpmuxSecretKey(hex: "5e1f0c2a9b8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f"))
let answers = try JSONSerialization.jsonObject(
with: Data(#"{"Which one?":"B","Pick a color\n(one)":["red","blue"],"é":"\u0001"}"#.utf8))
#expect(AcpmuxSecretKey.canonicalJSON(answers) == "{\"Pick a color\\n(one)\":[\"red\",\"blue\"],\"Which one?\":\"B\",\"é\":\"\\u0001\"}")
#expect(key.decisionProof(instance: "0123456789abcdef", session: "sess-1", permission: "perm-1", option: "allow_once",
nonce: "nonce-1", answers: answers)
== "3b11c2af201d95772c106a4c8463a54b94e615f85e88eac0700f7d657c24f0e9")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import Testing

func cancel() async throws { calls.withLock { $0.append("cancel") } }

func permission(id: String, option: String, decisionNonce: String?, personDecision: String?) async throws {
func permission(id: String, option: String, answers: Data?, decisionNonce: String?, personDecision: String?) async throws {
calls.withLock { $0.append("permission") }
}

Expand Down
Loading
Loading