request_user_secret use zeroize
This commit is contained in:
1 parent
a31f93a40c
commit
9cfe2005d6
30 files changed
+348
-314
No files matched your search
@@ -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([
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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..<bytes.endIndex)
|
||||
}
|
||||
|
||||
/// Copies only `range` into wipeable ownership, then clears the complete
|
||||
/// source buffer. This lets versioned secret envelopes discard their header
|
||||
/// without allocating an intermediate plaintext `Data` value.
|
||||
public init(consumingUTF8 bytes: inout Data, range: Range<Data.Index>) {
|
||||
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<UInt8>.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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -37,7 +37,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
|
||||
let onOutput: SSHExecOutputHandler?
|
||||
let continuation: CheckedContinuation<SSHExecResult, any Error>
|
||||
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<SSHExecResult, any Error>
|
||||
) {
|
||||
) 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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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..<end])))
|
||||
chunks.append(
|
||||
Chunk(
|
||||
UnsafeRawBufferPointer(rebasing: bytes[offset..<end]),
|
||||
zeroizesOnRelease: zeroizesConsumedBytes
|
||||
)
|
||||
)
|
||||
offset = end
|
||||
}
|
||||
count += bytes.count
|
||||
@@ -124,6 +137,9 @@ nonisolated final class SSHByteQueue {
|
||||
while remaining > 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 {
|
||||
|
||||
@@ -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..<result.endIndex) else {
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
return SSHSecret(consumingUTF8: &result)
|
||||
}
|
||||
guard byte == 0x5C else {
|
||||
@@ -281,74 +237,6 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
|
||||
/// Returns `nil` only for a legacy value. Once any complete or truncated
|
||||
/// current-format magic is present, malformed data fails closed.
|
||||
private static func currentSoftwareKeyPayload(in data: Data) throws -> Range<Data.Index>? {
|
||||
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..<data.endIndex
|
||||
}
|
||||
if !data.isEmpty && Self.softwareKeyMagic.starts(with: data) {
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isValidUTF8(_ data: Data, in range: Range<Data.Index>) -> 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-2
@@ -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 |
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
]))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import AgentKit
|
||||
import Foundation
|
||||
|
||||
func agentSecret(_ value: String) -> AgentSecret {
|
||||
var bytes = Data(value.utf8)
|
||||
return AgentSecret(consumingUTF8: &bytes)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user