From 9cfe2005d61dee209d4433b13e42ca29b3d41b51 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Mon, 21 Sep 2026 10:42:05 +0800 Subject: [PATCH] request_user_secret use zeroize --- FrameworkTests/AgentUserInputTests.swift | 22 +- .../LaunchFileLogServiceTests.swift | 2 +- FrameworkTests/SSHMetricsSamplerTests.swift | 2 +- Frameworks/Resources/Localizable.xcstrings | 237 ++++++++++++------ .../Agent/ConversationModel.swift | 5 +- Frameworks/TermindApplication/SSHSecret.swift | 15 +- .../TermindApplication/SecretMemory+Raw.swift | 8 + .../Agent/AgentBuiltInSSHTools.swift | 8 +- .../Agent/AgentConnections.swift | 2 +- .../SSH/SSHConnection.swift | 26 +- .../SSH/SSHConnectionWorker.swift | 27 +- .../SSH/SSHMetricsSampler.swift | 3 +- .../SSH/SSHPTYChannel.swift | 2 +- .../SSH/SSHStreamPump.swift | 24 +- .../Security/KeychainSecretStore.swift | 124 +-------- Memory/ARCHITECTURE.md | 8 +- Memory/MARKETING.md | 4 +- .../ExternalRequests/ExternalAuthWindow.swift | 7 +- TFMTests/AgentConnectionPoolTests.swift | 2 +- TFMTests/AgentHostAuthenticationTests.swift | 2 +- TFMTests/AgentRuntimeTests.swift | 5 +- TFMTests/AgentScratchTests.swift | 2 +- TFMTests/AgentSecretTestSupport.swift | 7 + TFMTests/AuthorizedKeyExportTests.swift | 60 ++--- TFMTests/ExternalRequestTests.swift | 18 +- TFMTests/MCPServerToolsTests.swift | 10 +- TFMTests/SFTPCopyToolTests.swift | 2 +- TFMTests/SSHForwardingPumpTests.swift | 22 ++ Termind.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- 30 files changed, 348 insertions(+), 314 deletions(-) create mode 100644 Frameworks/TermindApplication/SecretMemory+Raw.swift create mode 100644 TFMTests/AgentSecretTestSupport.swift diff --git a/FrameworkTests/AgentUserInputTests.swift b/FrameworkTests/AgentUserInputTests.swift index 8135e59d..2b7f7c4e 100644 --- a/FrameworkTests/AgentUserInputTests.swift +++ b/FrameworkTests/AgentUserInputTests.swift @@ -720,7 +720,7 @@ struct AgentUserInputTests { } @Test - func aSecretAnswersThroughItsOwnDraftAndNotTheSharedOne() async { + func aSecretAnswersThroughItsOwnDraftAndNotTheSharedOne() async throws { let conversation = ConversationModel() let pending = AgentUserInputRequest( questions: [AgentUserQuestion(id: "secret", prompt: "Sudo password?", kind: .secret)] @@ -736,12 +736,13 @@ struct AgentUserInputTests { #expect(conversation.canSubmitUserInput) conversation.submitUserInput() - #expect( - await answering.value - == .answered([ - AgentUserInputAnswer(questionID: "secret", value: .secret("hunter2")) - ]) - ) + guard case .answered(let answers) = await answering.value, + case .secret(let secret)? = answers.first?.value + else { + Issue.record("Expected one secret answer") + return + } + #expect(try secret.withUnsafeBytes { Array($0) } == Array("hunter2".utf8)) // Never left behind in the plain drafts, which are what the transcript // and the next question read from. #expect(conversation.userInputDraft(for: "secret").text.isEmpty) @@ -805,7 +806,7 @@ struct AgentUserInputTests { func aSecretIsNeverReportedBackToTheModel() { let rows = RequestUserInputTool.reported( [AgentUserQuestion(id: "secret", prompt: "Password?", kind: .secret)], - [AgentUserInputAnswer(questionID: "secret", value: .secret("hunter2"))] + [AgentUserInputAnswer(questionID: "secret", value: .secret(agentSecret("hunter2")))] ) #expect(field(rows[0], "answer") == .null) #expect(field(rows[0], "custom_text") == .null) @@ -817,6 +818,11 @@ struct AgentUserInputTests { row.objectValue?[key] } + private func agentSecret(_ value: String) -> AgentSecret { + var bytes = Data(value.utf8) + return AgentSecret(consumingUTF8: &bytes) + } + private func argumentsFor(_ toolName: String, timeout: Int) -> AgentJSONValue { if toolName == "request_user_secret" { return .object([ diff --git a/FrameworkTests/LaunchFileLogServiceTests.swift b/FrameworkTests/LaunchFileLogServiceTests.swift index 037fb256..6a6b58d3 100644 --- a/FrameworkTests/LaunchFileLogServiceTests.swift +++ b/FrameworkTests/LaunchFileLogServiceTests.swift @@ -204,7 +204,7 @@ private actor MetricsLogSession: SSHConnectionProtocol { init(result: SSHExecResult) { self.result = result } func connect(using _: SSHConnectionConfiguration) async throws {} func execute( - _: String, workingDirectory _: String?, stdin _: Data?, timeout _: TimeInterval, + _: String, workingDirectory _: String?, stdin _: SSHCommandInput?, timeout _: TimeInterval, outputLimit _: Int, onOutput _: SSHExecOutputHandler? ) async throws -> SSHExecResult { result } func openSFTPChannel() async throws -> any SFTPChannelProtocol { throw SFTPError.notConnected } diff --git a/FrameworkTests/SSHMetricsSamplerTests.swift b/FrameworkTests/SSHMetricsSamplerTests.swift index a0a70383..1e654a18 100644 --- a/FrameworkTests/SSHMetricsSamplerTests.swift +++ b/FrameworkTests/SSHMetricsSamplerTests.swift @@ -135,7 +135,7 @@ private actor FramedMetricsSession: SSHConnectionProtocol { func connect(using _: SSHConnectionConfiguration) async throws {} func execute( - _ command: String, workingDirectory _: String?, stdin _: Data?, timeout _: TimeInterval, + _ command: String, workingDirectory _: String?, stdin _: SSHCommandInput?, timeout _: TimeInterval, outputLimit _: Int, onOutput _: SSHExecOutputHandler? ) async throws -> SSHExecResult { executedCommands.append(command) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index cb51f308..46209638 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -6067,6 +6067,23 @@ } } }, + "Connecting to proxy" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在连接代理" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在連線代理" + } + } + } + }, "Connecting…" : { "extractionState" : "stale", "localizations" : { @@ -8123,15 +8140,9 @@ } } }, - "Destination Host (Port Forwarding)" : { + "Destination host" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Destination Host" - } - }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8146,26 +8157,15 @@ } } }, - "Destination Port" : { + "Destination Host (Port Forwarding)" : { "extractionState" : "manual", "localizations" : { - "zh-Hans" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "目标端口" + "value" : "Destination Host" } }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "目標連接埠" - } - } - } - }, - "Destination host" : { - "extractionState" : "manual", - "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8197,6 +8197,23 @@ } } }, + "Destination Port" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目标端口" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "目標連接埠" + } + } + } + }, "Destroy" : { "extractionState" : "manual", "localizations" : { @@ -12214,6 +12231,23 @@ } } }, + "HTTP proxy authentication failed." : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "HTTP代理认证失败。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "HTTP代理驗證失敗。" + } + } + } + }, "HTTP uploads are not available." : { "extractionState" : "manual", "localizations" : { @@ -18981,33 +19015,53 @@ } }, "Proxy authentication required" : { + "extractionState" : "stale", "localizations" : { - "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "需要代理认证" } }, - "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "需要代理驗證" } } + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "需要代理认证" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "需要代理驗證" + } + } } }, "Proxy connected" : { + "extractionState" : "stale", "localizations" : { - "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "代理已连接" } }, - "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "代理已連線" } } + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "代理已连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "代理已連線" + } + } } }, "Proxy for %@" : { "localizations" : { - "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "%@的代理" } }, - "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "%@的代理" } } - } - }, - "Connecting to proxy" : { - "localizations" : { - "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在连接代理" } }, - "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "正在連線代理" } } - } - }, - "Waiting for proxy authentication" : { - "localizations" : { - "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在等待代理认证" } }, - "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "正在等待代理驗證" } } + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@的代理" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@的代理" + } + } } }, "Proxy Hostname" : { @@ -20100,23 +20154,6 @@ } } }, - "Remote Port" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "远程端口" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "遠端連接埠" - } - } - } - }, "Remote path" : { "extractionState" : "stale", "localizations" : { @@ -20150,6 +20187,23 @@ } } }, + "Remote Port" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "远程端口" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "遠端連接埠" + } + } + } + }, "Remove" : { "extractionState" : "manual", "localizations" : { @@ -26036,24 +26090,6 @@ } } }, - "HTTP proxy authentication failed." : { - "localizations" : { - "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "HTTP代理认证失败。" } }, - "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "HTTP代理驗證失敗。" } } - } - }, - "The selected Identity cannot be updated on this device." : { - "localizations" : { - "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "无法在此设备上更新所选身份。" } }, - "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "無法在此裝置上更新所選身分。" } } - } - }, - "The SOCKS5 proxy rejected authentication." : { - "localizations" : { - "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "SOCKS5代理拒绝了认证。" } }, - "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "SOCKS5代理拒絕了驗證。" } } - } - }, "The proxy configuration is incomplete." : { "extractionState" : "stale", "localizations" : { @@ -26342,6 +26378,23 @@ } } }, + "The selected Identity cannot be updated on this device." : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法在此设备上更新所选身份。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法在此裝置上更新所選身分。" + } + } + } + }, "The selected identity has no usable password or private key." : { "extractionState" : "stale", "localizations" : { @@ -26660,6 +26713,23 @@ } } }, + "The SOCKS5 proxy rejected authentication." : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "SOCKS5代理拒绝了认证。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "SOCKS5代理拒絕了驗證。" + } + } + } + }, "The source file changed. Start a new transfer." : { "extractionState" : "manual", "localizations" : { @@ -29801,6 +29871,23 @@ } } }, + "Waiting for proxy authentication" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在等待代理认证" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在等待代理驗證" + } + } + } + }, "Waiting for response" : { "extractionState" : "stale", "localizations" : { @@ -30592,4 +30679,4 @@ } }, "version" : "1.2" -} +} \ No newline at end of file diff --git a/Frameworks/TermindApplication/Agent/ConversationModel.swift b/Frameworks/TermindApplication/Agent/ConversationModel.swift index 693abfb4..9083a8ed 100644 --- a/Frameworks/TermindApplication/Agent/ConversationModel.swift +++ b/Frameworks/TermindApplication/Agent/ConversationModel.swift @@ -2271,8 +2271,11 @@ public final class ConversationModel: Identifiable { let draft = userInputDraft(for: question.id) switch question.kind { case .secret: + var bytes = Data(secretInputDraft.utf8) + secretInputDraft = "" return AgentUserInputAnswer( - questionID: question.id, value: .secret(secretInputDraft) + questionID: question.id, + value: .secret(AgentSecret(consumingUTF8: &bytes)) ) case .text: return AgentUserInputAnswer(questionID: question.id, value: .text(draft.text)) diff --git a/Frameworks/TermindApplication/SSHSecret.swift b/Frameworks/TermindApplication/SSHSecret.swift index b85c0038..1c6df658 100644 --- a/Frameworks/TermindApplication/SSHSecret.swift +++ b/Frameworks/TermindApplication/SSHSecret.swift @@ -44,17 +44,8 @@ public nonisolated final class SSHSecret: @unchecked Sendable { /// Copies a freshly read UTF-8 buffer into wipeable ownership, then clears /// the source buffer before returning. The final allocation includes a NUL /// terminator without asking `Data` to grow or reallocate its plaintext. - public convenience init(consumingUTF8 bytes: inout Data) { - self.init(consumingUTF8: &bytes, range: bytes.startIndex..) { - precondition(range.lowerBound >= bytes.startIndex && range.upperBound <= bytes.endIndex) - let offset = bytes.distance(from: bytes.startIndex, to: range.lowerBound) - let count = bytes.distance(from: range.lowerBound, to: range.upperBound) + public init(consumingUTF8 bytes: inout Data) { + let count = bytes.count let pointer = UnsafeMutableRawPointer.allocate( byteCount: count + 1, alignment: MemoryLayout.alignment @@ -62,7 +53,7 @@ public nonisolated final class SSHSecret: @unchecked Sendable { if count > 0 { bytes.withUnsafeBytes { raw in guard let source = raw.baseAddress else { return } - pointer.copyMemory(from: source.advanced(by: offset), byteCount: count) + pointer.copyMemory(from: source, byteCount: count) } } pointer.storeBytes(of: UInt8(0), toByteOffset: count, as: UInt8.self) diff --git a/Frameworks/TermindApplication/SecretMemory+Raw.swift b/Frameworks/TermindApplication/SecretMemory+Raw.swift new file mode 100644 index 00000000..bc362fba --- /dev/null +++ b/Frameworks/TermindApplication/SecretMemory+Raw.swift @@ -0,0 +1,8 @@ +import Darwin + +nonisolated extension SecretMemory { + public static func clear(_ pointer: UnsafeMutableRawPointer, count: Int) { + guard count > 0 else { return } + _ = memset_s(pointer, count, 0, count) + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift index 68e5211e..885f79de 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift @@ -353,7 +353,9 @@ nonisolated public struct SSHExecTool: AgentToolDefinition, AgentToolSchemaBuild let arguments = try Arguments(invocation) let hostID = try arguments.uuid("host_id") let command = try arguments.string("command") - var input = arguments.optionalString("stdin").map { Data($0.utf8) } + var input = arguments.optionalString("stdin").map { + SSHCommandInput.plain(Data($0.utf8)) + } if let rawHandle = arguments.optionalString("secret_handle") { guard let handleID = UUID(uuidString: rawHandle) else { throw AgentToolError.invalidArguments( @@ -363,7 +365,7 @@ nonisolated public struct SSHExecTool: AgentToolDefinition, AgentToolSchemaBuild ) } let purpose = try arguments.string("stdin_purpose") - let secret: String + let secret: AgentSecret do { secret = try await context.secretBroker.consume( SecretHandle(id: handleID), @@ -383,7 +385,7 @@ nonisolated public struct SSHExecTool: AgentToolDefinition, AgentToolSchemaBuild + "Call request_user_secret again with matching values." ) } - input = Data(secret.utf8) + input = .secret(secret) } // Live output, so a command that takes a minute is a card // filling in rather than a spinner. Reported, never returned: diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift index 40c4b44a..4c8415e6 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift @@ -1284,7 +1284,7 @@ public actor AgentConnectionPool { /// the one-connection policy and is surfaced; anything that failed after the /// command started is also surfaced because replay could duplicate effects. public func execute( - _ command: String, workingDirectory: String?, stdin: Data?, + _ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval, outputLimit: Int, hostID: UUID, configuration override: RequestResolving? = nil, onOutput: SSHExecOutputHandler? = nil diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift index e0674d4e..18c09793 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift @@ -1,3 +1,4 @@ +import AgentKit import Foundation import Logging import TermindApplication @@ -23,6 +24,27 @@ public nonisolated struct SSHExecResult: Hashable, Sendable { } } +public nonisolated enum SSHCommandInput: Sendable { + case plain(Data) + case secret(AgentSecret) + + func append(to queue: SSHByteQueue) throws { + switch self { + case .plain(var bytes): + if !bytes.isEmpty, bytes.last != 0x0A { bytes.append(0x0A) } + queue.append(bytes) + case .secret(let secret): + defer { secret.clear() } + try secret.withUnsafeBytes { bytes in + queue.append(bytes) + if !bytes.isEmpty, bytes.last != 0x0A { + queue.append(contentsOf: [0x0A]) + } + } + } + } +} + /// The one failure that is safe to retry on a replacement connection. It is /// emitted only before the remote process has started. public nonisolated enum SSHCommandExecutionError: LocalizedError, Sendable { @@ -44,7 +66,7 @@ public nonisolated protocol SSHConnectionProtocol: AnyObject, Sendable { func connect(using configuration: SSHConnectionConfiguration) async throws func connect(using request: SSHConnectionRequest) async throws func execute( - _ command: String, workingDirectory: String?, stdin: Data?, + _ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler? ) async throws -> SSHExecResult func openSFTPChannel() async throws -> any SFTPChannelProtocol @@ -94,7 +116,7 @@ public actor SSHConnection: SSHConnectionProtocol { } public func execute( - _ command: String, workingDirectory: String?, stdin: Data?, + _ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler? ) async throws -> SSHExecResult { let cancellation = TransferCancellationToken() diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift index 2ffd6a7f..9120d54d 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift @@ -37,7 +37,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { let onOutput: SSHExecOutputHandler? let continuation: CheckedContinuation let channelLease: SSHChannelLease - let input = SSHByteQueue(chunkSize: 32_700) + let input = SSHByteQueue(chunkSize: 32_700, zeroizesConsumedBytes: true) var phase = Phase.opening var execChannel: OpaquePointer? var sentEOF = false @@ -52,11 +52,11 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { var completed = false init( - command: String, workingDirectory: String?, stdin: Data?, timeout: TimeInterval, + command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval, outputLimit: Int, cancellation: TransferCancellationToken, onOutput: SSHExecOutputHandler?, channelLease: SSHChannelLease, continuation: CheckedContinuation - ) { + ) throws { let effective: String if let cwd = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), !cwd.isEmpty { effective = "cd -- \(Self.shellQuote(cwd)) && /bin/sh -lc \(Self.shellQuote(command))" @@ -70,9 +70,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { self.onOutput = onOutput self.channelLease = channelLease self.continuation = continuation - var input = stdin ?? Data() - if !input.isEmpty, input.last != 0x0A { input.append(0x0A) } - self.input.append(input) + try stdin?.append(to: input) sentEOF = input.isEmpty } @@ -178,7 +176,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { } func execute( - _ command: String, workingDirectory: String?, stdin: Data?, timeout: TimeInterval, + _ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval, outputLimit: Int, cancellation: TransferCancellationToken, onOutput: SSHExecOutputHandler? ) async throws -> SSHExecResult { guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -206,10 +204,17 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { throwing: SSHCommandExecutionError.connectionUnavailable(TermindSSHError.disconnected)) return } - let context = SSHExecChannel( - command: command, workingDirectory: workingDirectory, stdin: stdin, - timeout: timeout, outputLimit: outputLimit, cancellation: cancellation, - onOutput: onOutput, channelLease: channelLease, continuation: continuation) + let context: SSHExecChannel + do { + context = try SSHExecChannel( + command: command, workingDirectory: workingDirectory, stdin: stdin, + timeout: timeout, outputLimit: outputLimit, cancellation: cancellation, + onOutput: onOutput, channelLease: channelLease, continuation: continuation) + } catch { + channelLease.release() + continuation.resume(throwing: error) + return + } commands[context.id] = context pendingOpens.insert(context.id) schedulePump() diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHMetricsSampler.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHMetricsSampler.swift index ee47c0d6..6d4446a7 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHMetricsSampler.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHMetricsSampler.swift @@ -165,7 +165,8 @@ public actor SSHMetricsSampler: MetricsSampling { let transportLimit = outputLimit.addingReportingOverflow(Self.framingAllowance) let raw = try await adapter.withConnection { connection in try await connection.execute( - framedCommand, workingDirectory: workingDirectory, stdin: stdin, + framedCommand, workingDirectory: workingDirectory, + stdin: stdin.map(SSHCommandInput.plain), timeout: timeout, outputLimit: transportLimit.overflow ? outputLimit : transportLimit.partialValue, onOutput: onOutput) } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index 54f6dc9e..61097990 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -927,7 +927,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { #"umask 077; dir="$HOME/.ssh"; file="$dir/authorized_keys"; mkdir -p "$dir" && touch "$file" && chmod 700 "$dir" && chmod 600 "$file" && IFS= read -r comment && IFS= read -r key && { LC_ALL=C grep -Fqx -- "$key" "$file" || printf '%s\n%s\n' "$comment" "$key" >> "$file"; }"# for input in inputs { let result = try await connection.execute( - command, workingDirectory: nil, stdin: input, timeout: 20, + command, workingDirectory: nil, stdin: .plain(input), timeout: 20, outputLimit: 64 * 1024, onOutput: nil) guard result.exitCode == 0 else { let detail = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHStreamPump.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHStreamPump.swift index c6f42e7c..e8190f8e 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHStreamPump.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHStreamPump.swift @@ -1,6 +1,7 @@ import CSSH2 import Darwin import Foundation +import TermindApplication /// Capacity and scheduling are independent: a larger receive allowance never /// grants one flow a larger turn or reserves additional queue memory. @@ -38,24 +39,31 @@ nonisolated struct SSHForwardingTuning: Sendable { /// new socket data arrives while an earlier write is blocked. nonisolated final class SSHByteQueue { let chunkSize: Int + let zeroizesConsumedBytes: Bool - init(chunkSize: Int = 32 * 1024) { + init(chunkSize: Int = 32 * 1024, zeroizesConsumedBytes: Bool = false) { precondition(chunkSize > 0) self.chunkSize = chunkSize + self.zeroizesConsumedBytes = zeroizesConsumedBytes } private final class Chunk { let bytes: UnsafeMutableRawPointer let size: Int + let zeroizesOnRelease: Bool var offset = 0 - init(_ source: UnsafeRawBufferPointer) { + init(_ source: UnsafeRawBufferPointer, zeroizesOnRelease: Bool) { size = source.count + self.zeroizesOnRelease = zeroizesOnRelease bytes = .allocate(byteCount: size, alignment: 1) bytes.copyMemory(from: source.baseAddress!, byteCount: size) } - deinit { bytes.deallocate() } + deinit { + if zeroizesOnRelease { SecretMemory.clear(bytes, count: size) } + bytes.deallocate() + } } private var chunks: [Chunk?] = [] @@ -70,7 +78,12 @@ nonisolated final class SSHByteQueue { var offset = 0 while offset < bytes.count { let end = min(offset + chunkSize, bytes.count) - chunks.append(Chunk(UnsafeRawBufferPointer(rebasing: bytes[offset.. 0 { let chunk = chunks[head]! let consumed = min(remaining, chunk.size - chunk.offset) + if zeroizesConsumedBytes { + SecretMemory.clear(chunk.bytes + chunk.offset, count: consumed) + } chunk.offset += consumed remaining -= consumed if chunk.offset == chunk.size { diff --git a/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift b/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift index 038f8ba3..72f091d1 100644 --- a/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift +++ b/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift @@ -30,8 +30,6 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { private static let passwordService = "com.akinokaede.termind.passwords.v1" private static let softwareKeyService = "com.akinokaede.termind.ssh-software-keys.v1" private static let enclaveService = "com.akinokaede.termind.secure-enclave-handles.v1" - private static let softwareKeyMagic = Data("termind-ssh-key\0".utf8) - private static let softwareKeyFormatVersion: UInt8 = 0x01 private let accessGroup: String @@ -92,14 +90,14 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { { try validate(account, prefix: "termind.key.") guard - var data = try data( + let data = try data( for: account, service: service(for: storage), synchronizable: storage == .iCloud ) else { return nil } switch storage { case .thisDevice, .iCloud: - return .software(try Self.decodeSoftwareKey(from: &data)) + return .software(try JSONDecoder().decode(SSHPrivateKeySecret.self, from: data)) case .secureEnclave: guard !data.isEmpty else { throw KeychainSecretError.unexpectedValue } return .secureEnclave(SecureEnclaveKeyCredential(dataRepresentation: data)) @@ -127,7 +125,7 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { defer { SecretMemory.clear(&data) } switch storage { case .thisDevice, .iCloud: - return .software(try Self.decodeSoftwareKeyRuntimeSecret(from: &data)) + return .software(try Self.decodePrivateKeySecret(from: &data)) case .secureEnclave: guard !data.isEmpty else { throw KeychainSecretError.unexpectedValue } return .secureEnclave(SecureEnclaveKeyCredential(dataRepresentation: data)) @@ -141,7 +139,7 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { var data: Data? switch (value, storage) { case (.software(let secret), .thisDevice), (.software(let secret), .iCloud): - data = Self.encodeSoftwareKey(secret) + data = try JSONEncoder().encode(secret) case (.secureEnclave(let credential), .secureEnclave): data = credential.dataRepresentation case (nil, _): @@ -161,50 +159,11 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { ) } - static func encodeSoftwareKey(_ secret: SSHPrivateKeySecret) -> Data { - var data = Data() - data.reserveCapacity(Self.softwareKeyMagic.count + 1 + secret.privateKey.utf8.count) - data.append(Self.softwareKeyMagic) - data.append(Self.softwareKeyFormatVersion) - data.append(contentsOf: secret.privateKey.utf8) - return data - } - - static func decodeSoftwareKey(from data: inout Data) throws -> SSHPrivateKeySecret { - defer { SecretMemory.clear(&data) } - if let payload = try currentSoftwareKeyPayload(in: data) { - guard let privateKey = String(data: data[payload], encoding: .utf8) else { - throw KeychainSecretError.unexpectedValue - } - return SSHPrivateKeySecret(privateKey: privateKey) - } - do { - return try JSONDecoder().decode(SSHPrivateKeySecret.self, from: data) - } catch { - throw KeychainSecretError.unexpectedValue - } - } - - /// Decodes the current envelope directly into one-shot ownership. The - /// legacy JSON path remains strict and never materializes the private key as - /// an immutable Swift `String`. - static func decodeSoftwareKeyRuntimeSecret(from data: inout Data) throws -> SSHSecret { - defer { SecretMemory.clear(&data) } - if let payload = try currentSoftwareKeyPayload(in: data) { - guard isValidUTF8(data, in: payload) else { - throw KeychainSecretError.unexpectedValue - } - return SSHSecret(consumingUTF8: &data, range: payload) - } - return try decodeLegacySoftwareKeyRuntimeSecret(from: &data) - } - /// Strictly decodes the legacy single-field JSON envelope without creating /// an immutable Swift `String` containing the private key. Corrupted shapes /// fail closed instead of being searched for a matching field substring. - private static func decodeLegacySoftwareKeyRuntimeSecret(from data: inout Data) throws - -> SSHSecret - { + static func decodePrivateKeySecret(from data: inout Data) throws -> SSHSecret { + defer { SecretMemory.clear(&data) } var index = data.startIndex func skipWhitespace() { while index < data.endIndex, [0x20, 0x09, 0x0A, 0x0D].contains(data[index]) { @@ -241,9 +200,6 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { guard index == data.endIndex else { throw KeychainSecretError.unexpectedValue } - guard isValidUTF8(result, in: result.startIndex.. Range? { - if data.starts(with: Self.softwareKeyMagic) { - let versionIndex = data.index(data.startIndex, offsetBy: Self.softwareKeyMagic.count) - guard versionIndex < data.endIndex else { throw KeychainSecretError.unexpectedValue } - guard data[versionIndex] == Self.softwareKeyFormatVersion else { - throw KeychainSecretError.unexpectedValue - } - let payloadStart = data.index(after: versionIndex) - guard payloadStart < data.endIndex else { throw KeychainSecretError.unexpectedValue } - return payloadStart..) -> Bool { - var index = range.lowerBound - func continuation() -> UInt8? { - guard index < range.upperBound else { return nil } - let byte = data[index] - guard byte >= 0x80 && byte <= 0xBF else { return nil } - index = data.index(after: index) - return byte - } - - while index < range.upperBound { - let first = data[index] - index = data.index(after: index) - switch first { - case 0x00...0x7F: - continue - case 0xC2...0xDF: - guard continuation() != nil else { return false } - case 0xE0: - guard let second = continuation(), second >= 0xA0, continuation() != nil else { - return false - } - case 0xE1...0xEC, 0xEE...0xEF: - guard continuation() != nil, continuation() != nil else { return false } - case 0xED: - guard let second = continuation(), second <= 0x9F, continuation() != nil else { - return false - } - case 0xF0: - guard - let second = continuation(), second >= 0x90, - continuation() != nil, continuation() != nil - else { return false } - case 0xF1...0xF3: - guard - continuation() != nil, continuation() != nil, continuation() != nil - else { return false } - case 0xF4: - guard - let second = continuation(), second <= 0x8F, - continuation() != nil, continuation() != nil - else { return false } - default: - return false - } - } - return true - } - private static func hex(_ byte: UInt8) -> UInt8? { switch byte { case 0x30...0x39: byte - 0x30 diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 2dd5d70c..8e556b99 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -103,9 +103,7 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r leaving retry state; retry/session/Agent/Metrics/transfer state retains only reentry flags and non-secret Identity/Key provenance. Saved material is reread from Keychain for another physical connection; temporary Passwords and every Passphrase must be entered again. UI authentication fields clear synchronously on submission. Availability checks query - Keychain metadata without reading secret data. New software Keys use a magic- and version-prefixed UTF-8 envelope that - moves its payload directly into one-shot ownership; the legacy JSON envelope remains read-only, is parsed strictly and - fails closed. + Keychain metadata without reading secret data; the legacy software-Key JSON envelope is parsed strictly and fails closed. - Saved iCloud Passwords and software Keys cannot be moved to device-only storage: editors disable that destination and application writes reject it before touching Keychain. New credentials may start device-only, and device-only credentials may move to iCloud. @@ -331,6 +329,10 @@ factories/Sendable buffer handlers. Timers/workers enter explicit short MainActo SFTP. Standalone Assistant can discover Hosts without sidebar attachment; ask only for ambiguity. App auth handles SSH prerequisites; `request_user_secret` is for command secrets. Suggestions create user-operated cards, never execute. Deterministic provider schemas/cache keys; cached tokens never double-count usage. +- `request_user_secret` converts the UI's unavoidable transient `String` into AgentKit-owned `AgentSecret` memory at submission. Its broker + transfers one-shot ownership without plaintext `String` copies; `ssh_exec` copies borrowed bytes directly into a zeroizing stdin queue + that wipes sent prefixes and all remaining bytes on teardown. AgentKit and Termind deliberately keep separate `SecretMemory` + implementations so neither package owns the other's security boundary. - Concurrent batches require every call parallel; preflight may tighten (`ssh_exec` proven-safe only, destructive MCP sequential). Answers stay model-ordered, including refusals/cancellation. Questions/approvals queue singly; hooks cannot remove structural authorization. Command classification unwraps bounded known syntax; unknown/complex/unsupported input fails closed as state-changing. diff --git a/Memory/MARKETING.md b/Memory/MARKETING.md index a16206c9..9a8587d8 100644 --- a/Memory/MARKETING.md +++ b/Memory/MARKETING.md @@ -54,7 +54,7 @@ TERMIND_MARKETING_RUNTIME=com.apple.CoreSimulator.SimRuntime.iOS-27-0 \ ``` Recapture affected scenes in **every locale**, regenerate matching frames/boards only. Vault/Metrics/Assistant map to mobile -boards 04–06; SSH/SFTP to 02–03. First boards additionally depend on iPhone SSH and iPad SFTP, so those source changes affect +boards 04–06; SSH/SFTP to 02–03. First boards additionally depend on iPhone Assistant and iPad SFTP, so those source changes affect all nine first boards. ### Mac @@ -112,7 +112,7 @@ iPad 13-inch 2752×2064. | Board | iPhone / iPad | Mac | | --- | --- | --- | -| 01 | Mac Workspace + iPhone SSH + iPad SFTP | same | +| 01 | Mac Workspace + iPhone Assistant + iPad SFTP | same | | 02 | SSH | Workspace | | 03 | SFTP | Metrics | | 04 | Metrics | Assistant | diff --git a/TFM/Features/ExternalRequests/ExternalAuthWindow.swift b/TFM/Features/ExternalRequests/ExternalAuthWindow.swift index 36ada173..9f6971c1 100644 --- a/TFM/Features/ExternalRequests/ExternalAuthWindow.swift +++ b/TFM/Features/ExternalRequests/ExternalAuthWindow.swift @@ -302,11 +302,14 @@ private struct ExternalSecretRequestForm: View { private func submitSecret() { guard let question, !secret.isEmpty else { return } - let value = secret + var bytes = Data(secret.utf8) secret = "" submit( .answered([ - AgentUserInputAnswer(questionID: question.id, value: .secret(value)) + AgentUserInputAnswer( + questionID: question.id, + value: .secret(AgentSecret(consumingUTF8: &bytes)) + ) ])) } diff --git a/TFMTests/AgentConnectionPoolTests.swift b/TFMTests/AgentConnectionPoolTests.swift index aeaff91a..fc4fc68e 100644 --- a/TFMTests/AgentConnectionPoolTests.swift +++ b/TFMTests/AgentConnectionPoolTests.swift @@ -395,7 +395,7 @@ private actor StubSSHConnection: SSHConnectionProtocol { func connect(using configuration: SSHConnectionConfiguration) async throws {} func execute( - _ command: String, workingDirectory: String?, stdin: Data?, + _ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler? ) async throws -> SSHExecResult { executeCount += 1 diff --git a/TFMTests/AgentHostAuthenticationTests.swift b/TFMTests/AgentHostAuthenticationTests.swift index 3b359ea2..38aeb63e 100644 --- a/TFMTests/AgentHostAuthenticationTests.swift +++ b/TFMTests/AgentHostAuthenticationTests.swift @@ -1401,7 +1401,7 @@ private actor AuthenticationSSHConnection: SSHConnectionProtocol { func connect(using configuration: SSHConnectionConfiguration) async throws {} func execute( - _ command: String, workingDirectory: String?, stdin: Data?, + _ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler? ) async throws -> SSHExecResult { SSHExecResult( diff --git a/TFMTests/AgentRuntimeTests.swift b/TFMTests/AgentRuntimeTests.swift index bd6056cc..e4666f8e 100644 --- a/TFMTests/AgentRuntimeTests.swift +++ b/TFMTests/AgentRuntimeTests.swift @@ -943,7 +943,7 @@ struct AgentRuntimeTests { let binding = SecretBinding( runID: UUID(), toolName: "ssh_exec", hostID: UUID(), purpose: "sudo" ) - let handle = await broker.issue("password", binding: binding) + let handle = try await broker.issue(agentSecret("password"), binding: binding) await #expect(throws: SecretBrokerError.bindingMismatch) { try await broker.consume( @@ -954,7 +954,8 @@ struct AgentRuntimeTests { ) ) } - #expect(try await broker.consume(handle, matching: binding) == "password") + let consumed = try await broker.consume(handle, matching: binding) + #expect(try consumed.withUnsafeBytes { Array($0) } == Array("password".utf8)) await #expect(throws: SecretBrokerError.notFound) { try await broker.consume(handle, matching: binding) } diff --git a/TFMTests/AgentScratchTests.swift b/TFMTests/AgentScratchTests.swift index 38b05777..e3d509d7 100644 --- a/TFMTests/AgentScratchTests.swift +++ b/TFMTests/AgentScratchTests.swift @@ -1139,7 +1139,7 @@ private actor StubTransferSSHConnection: SSHConnectionProtocol { func connect(using configuration: SSHConnectionConfiguration) async throws {} func execute( - _ command: String, workingDirectory: String?, stdin: Data?, timeout: TimeInterval, + _ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler? ) async throws -> SSHExecResult { throw AgentToolError.invalidArguments("Unexpected exec in scratch transfer test") diff --git a/TFMTests/AgentSecretTestSupport.swift b/TFMTests/AgentSecretTestSupport.swift new file mode 100644 index 00000000..2e64705c --- /dev/null +++ b/TFMTests/AgentSecretTestSupport.swift @@ -0,0 +1,7 @@ +import AgentKit +import Foundation + +func agentSecret(_ value: String) -> AgentSecret { + var bytes = Data(value.utf8) + return AgentSecret(consumingUTF8: &bytes) +} diff --git a/TFMTests/AuthorizedKeyExportTests.swift b/TFMTests/AuthorizedKeyExportTests.swift index adf4b9f2..e7be522c 100644 --- a/TFMTests/AuthorizedKeyExportTests.swift +++ b/TFMTests/AuthorizedKeyExportTests.swift @@ -36,69 +36,29 @@ struct AuthorizedKeyExportTests { ) } - @Test - func softwareKeyEnvelopeRoundTripsWithoutKeepingTheSourceBuffer() throws { - let privateKey = "-----BEGIN TEST-----\nsecret-钥匙\n-----END TEST-----" - let stored = SSHPrivateKeySecret(privateKey: privateKey) - var encoded = KeychainSecretStore.encodeSoftwareKey(stored) - - #expect(encoded.starts(with: Data("termind-ssh-key\0".utf8) + Data([0x01]))) - let decoded = try KeychainSecretStore.decodeSoftwareKey(from: &encoded) - #expect(encoded.isEmpty) - #expect(decoded == stored) - - var runtimeEncoded = KeychainSecretStore.encodeSoftwareKey(stored) - let runtime = try KeychainSecretStore.decodeSoftwareKeyRuntimeSecret(from: &runtimeEncoded) - #expect(runtimeEncoded.isEmpty) - #expect(runtime.matches(privateKey)) - } - @Test func legacyPrivateKeyEnvelopeRequiresACompleteJSONObject() throws { var encoded = try JSONEncoder().encode( SSHPrivateKeySecret(privateKey: "-----BEGIN TEST-----\nsecret\n-----END TEST-----") ) - let secret = try KeychainSecretStore.decodeSoftwareKeyRuntimeSecret(from: &encoded) + let secret = try KeychainSecretStore.decodePrivateKeySecret(from: &encoded) #expect(encoded.isEmpty) #expect(secret.matches("-----BEGIN TEST-----\nsecret\n-----END TEST-----")) #expect(throws: KeychainSecretError.self) { var data = Data(#"{"privateKey":"secret""#.utf8) - _ = try KeychainSecretStore.decodeSoftwareKeyRuntimeSecret( + _ = try KeychainSecretStore.decodePrivateKeySecret( from: &data ) } #expect(throws: KeychainSecretError.self) { var data = Data(#"{"privateKey":"secret"} trailing"#.utf8) - _ = try KeychainSecretStore.decodeSoftwareKeyRuntimeSecret( + _ = try KeychainSecretStore.decodePrivateKeySecret( from: &data ) } } - @Test - func currentSoftwareKeyEnvelopeRejectsMalformedValuesWithoutJSONFallback() { - let magic = Data("termind-ssh-key\0".utf8) - let malformed: [Data] = [ - magic.prefix(5), - magic, - magic + Data([0x02]) + Data(#"{"privateKey":"fallback"}"#.utf8), - magic + Data([0x01]), - magic + Data([0x01, 0xC0, 0x80]), - ].map(Data.init) - - for value in malformed { - #expect(throws: KeychainSecretError.self) { - var data = value - _ = try KeychainSecretStore.decodeSoftwareKey(from: &data) - } - #expect(throws: KeychainSecretError.self) { - var data = value - _ = try KeychainSecretStore.decodeSoftwareKeyRuntimeSecret(from: &data) - } - } - } - @Test func multipleKeysReuseOneAuthenticatedConnection() async throws { let password = SSHSecret("one-use") @@ -150,11 +110,21 @@ private actor AuthorizedKeyConnection: SSHConnectionProtocol { } func execute( - _ command: String, workingDirectory: String?, stdin: Data?, + _ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler? ) async throws -> SSHExecResult { executions += 1 - inputs.append(stdin.map { String(decoding: $0, as: UTF8.self) } ?? "") + let input: String + switch stdin { + case .plain(let bytes): + input = String(decoding: bytes, as: UTF8.self) + case .secret(let secret): + defer { secret.clear() } + input = try secret.withUnsafeBytes { String(decoding: $0, as: UTF8.self) } + case nil: + input = "" + } + inputs.append(input) return SSHExecResult( stdout: "", stderr: "", exitCode: 0, stdoutTruncated: false, stderrTruncated: false diff --git a/TFMTests/ExternalRequestTests.swift b/TFMTests/ExternalRequestTests.swift index 9979b2e3..4ab9577d 100644 --- a/TFMTests/ExternalRequestTests.swift +++ b/TFMTests/ExternalRequestTests.swift @@ -242,15 +242,11 @@ struct ExternalAuthCoordinatorTests { return false } - coordinator.respondSecret( - .answered([ - AgentUserInputAnswer(questionID: "secret", value: .secret("value")) - ])) - #expect( - await task.value - == .answered([ - AgentUserInputAnswer(questionID: "secret", value: .secret("value")) - ])) + let response = AgentUserInputResponse.answered([ + AgentUserInputAnswer(questionID: "secret", value: .secret(agentSecret("value"))) + ]) + coordinator.respondSecret(response) + #expect(await task.value == response) let ordinary = AgentUserInputRequest( questions: [AgentUserQuestion(id: "text", prompt: "Name", kind: .text)] @@ -338,7 +334,9 @@ struct ExternalAuthCoordinatorTests { try await waitUntil { coordinator.current?.id == request.id } coordinator.respondSecret( .answered([ - AgentUserInputAnswer(questionID: "secret", value: .secret("value")) + AgentUserInputAnswer( + questionID: "secret", value: .secret(agentSecret("value")) + ) ])) guard case .answered = await secret.value else { Issue.record("expected the secret request to be answered") diff --git a/TFMTests/MCPServerToolsTests.swift b/TFMTests/MCPServerToolsTests.swift index 2649aa9a..138e76a1 100644 --- a/TFMTests/MCPServerToolsTests.swift +++ b/TFMTests/MCPServerToolsTests.swift @@ -188,7 +188,9 @@ struct MCPServerToolsTests { } authentication.respondSecret( .answered([ - AgentUserInputAnswer(questionID: "secret", value: .secret("TOP-SECRET")) + AgentUserInputAnswer( + questionID: "secret", value: .secret(agentSecret("TOP-SECRET")) + ) ])) let result = try await call.value @@ -206,7 +208,7 @@ struct MCPServerToolsTests { purpose: "sudo password" ) ) - #expect(consumed == "TOP-SECRET") + #expect(try consumed.withUnsafeBytes { Array($0) } == Array("TOP-SECRET".utf8)) await #expect(throws: SecretBrokerError.notFound) { _ = try await broker.consume( handle, @@ -259,8 +261,8 @@ struct MCPServerToolsTests { let server = makeMCPServerTools(externalAuthentication: authentication) let broker = SecretBroker() let runID = UUID() - _ = await broker.issue( - "secret", + _ = try await broker.issue( + agentSecret("secret"), binding: SecretBinding( runID: runID, toolName: "ssh_exec", hostID: UUID(), purpose: "test" ) diff --git a/TFMTests/SFTPCopyToolTests.swift b/TFMTests/SFTPCopyToolTests.swift index a2671c75..bbff1c2a 100644 --- a/TFMTests/SFTPCopyToolTests.swift +++ b/TFMTests/SFTPCopyToolTests.swift @@ -500,7 +500,7 @@ private actor CopySSHConnection: SSHConnectionProtocol { func connect(using configuration: SSHConnectionConfiguration) async throws {} func execute( - _ command: String, workingDirectory: String?, stdin: Data?, timeout: TimeInterval, + _ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler? ) async throws -> SSHExecResult { throw AgentToolError.invalidArguments("Unexpected exec in SFTP copy test") diff --git a/TFMTests/SSHForwardingPumpTests.swift b/TFMTests/SSHForwardingPumpTests.swift index 1412cee4..a22cbd56 100644 --- a/TFMTests/SSHForwardingPumpTests.swift +++ b/TFMTests/SSHForwardingPumpTests.swift @@ -1,3 +1,4 @@ +import AgentKit import CSSH2 import Darwin import Foundation @@ -7,6 +8,27 @@ import Testing @Suite struct SSHForwardingPumpTests { + @Test + func secretCommandInputMovesIntoTheZeroizingQueueAndAddsANewline() throws { + var bytes = Data("sudo-password".utf8) + let secret = AgentSecret(consumingUTF8: &bytes) + let queue = SSHByteQueue(chunkSize: 4, zeroizesConsumedBytes: true) + + try SSHCommandInput.secret(secret).append(to: queue) + + #expect(secret.isConsumed) + #expect(bytes.isEmpty) + var queued: [UInt8] = [] + while !queue.isEmpty { + let count = queue.withFirstBytes { bytes -> Int in + queued.append(contentsOf: bytes) + return bytes.count + } + queue.consume(count) + } + #expect(queued == Array("sudo-password\n".utf8)) + } + @Test func blockedWriteKeepsItsPointerLengthAndBytesWhileMoreDataArrives() { let queue = SSHByteQueue() queue.append(Data(repeating: 0x31, count: 100)) diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index fb31e4cb..df1b00d1 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -2831,7 +2831,7 @@ repositoryURL = "https://github.com/AkinoKaede/AgentKit.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.13.3; + minimumVersion = 0.14.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8d4ea05f..ff19b10d 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/AkinoKaede/AgentKit.git", "state" : { - "revision" : "591375990284bf90858e7f049c516777432970f7", - "version" : "0.13.3" + "revision" : "ffc4aa41c8854142aa47c35231e57f56f961ba9f", + "version" : "0.14.0" } }, {