Tool names are protocol surface: they appear in the MCP export list, persisted disabled-tool preferences, audit records, and presentation registries. Spelling them as literals at each of those sites let one place drift from another with no compile error to catch it. Add TermindToolName as the single source of truth for the 25 names Termind owns, and route every non-documentation reference through it. The registry lives in TermindApplication because presentation cannot import TermindRemoteInfrastructure, and TermindApplication cannot depend on it either. Names owned by AgentKit (request_user_secret, present_plan, the scratch_* build tools) and retired aliases (ssh_exec, ssh_wait) stay as literals, as do the prose in MCP instructions and descriptor summaries. The legacyToolNames compatibility list is a historical data contract and is unchanged. This also removes the sshExecRunToolName and sshExecStdinToolName constants from TermindAgentPolicy, which the registry replaces.
276 lines
14 KiB
Swift
276 lines
14 KiB
Swift
import AgentKit
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
|
|
nonisolated enum SSHExecResults: AgentToolSchemaBuilding {
|
|
static func snapshot(_ invocation: AgentToolInvocation, _ value: AgentJSONValue) -> AgentToolResult {
|
|
var fields = value.objectValue ?? [:]
|
|
let capture = fields.removeValue(forKey: "output_capture").flatMap(AgentOutputCaptureInfo.init(json:))
|
|
let id = fields.removeValue(forKey: "output_capture_id")
|
|
let revision = fields.removeValue(forKey: "output_revision")
|
|
let totalLines = fields.removeValue(forKey: "output_total_lines")
|
|
let lineOffset = fields.removeValue(forKey: "output_line_offset")
|
|
let truncated = fields.removeValue(forKey: "output_truncated")?.boolValue ?? false
|
|
var result = result(invocation, .object(fields), truncated: truncated)
|
|
result.isError = fields["status"]?.stringValue == "failed"
|
|
result.outputCapture = capture
|
|
result.metadata["output_capture_id"] = id
|
|
result.metadata["output_revision"] = revision
|
|
result.metadata["output_total_lines"] = totalLines
|
|
result.metadata["output_line_offset"] = lineOffset
|
|
return result
|
|
}
|
|
|
|
static func handle(_ invocation: AgentToolInvocation) throws -> UUID {
|
|
guard let raw = try Arguments(invocation).optionalString("handle"), let handle = UUID(uuidString: raw) else {
|
|
throw AgentToolError.invalidArguments("handle must be a UUID returned by ssh_exec_run.")
|
|
}
|
|
return handle
|
|
}
|
|
|
|
static func handleArguments(_ input: AgentToolArgumentDetailInput) -> [AgentToolDetail.Item] {
|
|
guard let handle = input.arguments["handle"]?.stringValue else { return [] }
|
|
return [AgentToolDetailFormatting.field("Handle", handle, locale: input.locale, monospaced: true)]
|
|
}
|
|
}
|
|
|
|
nonisolated public struct SSHExecOutputTool: AgentToolDefinition, AgentToolSchemaBuilding {
|
|
public static let presenter = AgentToolDetailPresenter(
|
|
id: "builtin.ssh_exec_output",
|
|
layout: .init(singleLineHeader: true, boundsApprovalHeight: true, truncateStrategy: .tail),
|
|
present: { AgentToolDetailFormatting.genericItems($0.result, locale: $0.locale) },
|
|
presentArguments: SSHExecResults.handleArguments)
|
|
public let background: AgentBackgroundCommands
|
|
public let scope: UUID
|
|
public var truncateStrategy: AgentTruncateStrategy { .tail }
|
|
|
|
public init(background: AgentBackgroundCommands, scope: UUID) {
|
|
self.background = background
|
|
self.scope = scope
|
|
}
|
|
|
|
public var descriptor: AgentToolDescriptor {
|
|
Self.descriptor(
|
|
TermindToolName.sshExecOutput,
|
|
"Return the current cumulative ssh_exec_run snapshot immediately. Reads do not consume output or extend retention. No waiting or timeout. Combined output uses complete-line tail selection at 64 KiB / 2000 lines; truncation notices include the generated scratch path for scratch_read or scratch_search.",
|
|
properties: ["handle": Self.string(max: 64)], required: ["handle"],
|
|
target: .host, approvalPolicy: .approve, concurrency: .parallel,
|
|
presentation: .init(
|
|
symbol: "text.alignleft", activity: .semanticLabel(.commandOutput),
|
|
output: .streams, actionKind: .read))
|
|
}
|
|
|
|
public func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
|
|
-> AgentToolResult
|
|
{
|
|
SSHExecResults.snapshot(
|
|
invocation,
|
|
try await background.output(SSHExecResults.handle(invocation), scope: scope) {
|
|
"\(scope.uuidString.lowercased())/outputs/ssh-exec-output-\(UUID().uuidString.lowercased()).txt"
|
|
})
|
|
}
|
|
}
|
|
|
|
nonisolated public struct SSHExecStdinTool: AgentToolDefinition, AgentToolSchemaBuilding {
|
|
public static let presenter = AgentToolDetailPresenter(
|
|
id: "builtin.ssh_exec_stdin",
|
|
present: { AgentToolDetailFormatting.genericItems($0.result, locale: $0.locale) },
|
|
presentArguments: presentArguments)
|
|
public let background: AgentBackgroundCommands
|
|
public let scope: UUID
|
|
private let planning: Bool
|
|
|
|
public init(background: AgentBackgroundCommands, scope: UUID, planning: Bool = false) {
|
|
self.background = background
|
|
self.scope = scope
|
|
self.planning = planning
|
|
}
|
|
|
|
public var descriptor: AgentToolDescriptor {
|
|
Self.descriptor(
|
|
TermindToolName.sshExecStdin,
|
|
"""
|
|
Write to the stdin of the existing background command identified by handle, on that command's host. \
|
|
type=text requires stdin (empty is a zero-byte write); \
|
|
type=secret consumes a one-shot secret_handle from request_user_secret and delivers the user's \
|
|
securely entered bytes directly to that command's stdin. The opaque secret_handle is not the \
|
|
secret itself. Plaintext is intentionally absent from arguments, review context and results; \
|
|
this tool does not retrieve saved SSH credentials. Use secret input for the command and purpose \
|
|
the user authorized, such as answering its password prompt. Name the host, receiving command and \
|
|
intended use in the secure-input prompt. If existing user instructions do not cover that use and \
|
|
destination, obtain explicit user confirmation before requesting the secret; use request_user_input \
|
|
when available so the answer is visible to review. \
|
|
For review, assess the receiving command, destination and authorized use, not the hidden secret's \
|
|
literal value. Missing plaintext alone does not imply exfiltration. A handle is not blanket \
|
|
authorization: commands that echo, log, store or forward the secret need separate consideration. \
|
|
type=eof accepts neither payload and closes stdin after queued writes. \
|
|
Omit unused payload fields or set them to null. No automatic newline: send \
|
|
an explicit text newline if needed. Repeated EOF is harmless, writes after EOF fail. \
|
|
Returns handle, status (written/closed), bytes_written and stdin_closed. bytes_written counts \
|
|
encoded bytes handed to SSH, not confirmed remote consumption. Never blindly retry a failed write.
|
|
""",
|
|
properties: [
|
|
"handle": Self.string(max: 64), "type": Self.enumeration(["text", "secret", "eof"]),
|
|
"stdin": Self.string(max: 131_072), "secret_handle": Self.string(max: 64),
|
|
], required: ["handle", "type"], target: .host, approvalPolicy: .ask,
|
|
presentation: .init(symbol: "text.cursor", activity: .semanticLabel(.remoteCommand), actionKind: .write))
|
|
}
|
|
|
|
private func inputType(_ invocation: AgentToolInvocation) throws -> String {
|
|
let args = try Arguments(invocation)
|
|
let type = try args.string("type")
|
|
// Strict provider schemas represent unused optional payloads as null.
|
|
let payloads = args.object.filter { $0.value != .null }
|
|
let text = payloads["stdin"]
|
|
let secret = payloads["secret_handle"]
|
|
switch type {
|
|
case "text" where text?.stringValue != nil && secret == nil: break
|
|
case "secret" where text == nil && secret?.stringValue.flatMap(UUID.init(uuidString:)) != nil: break
|
|
case "eof" where text == nil && secret == nil: break
|
|
default:
|
|
throw AgentToolError.invalidArguments(
|
|
"Supply only stdin for text, only secret_handle for secret, or neither for eof.")
|
|
}
|
|
return type
|
|
}
|
|
|
|
public func preflight(_ invocation: AgentToolInvocation) async throws -> AgentToolPreflight {
|
|
let type = try inputType(invocation)
|
|
let command = try await background.command(SSHExecResults.handle(invocation), scope: scope)
|
|
var checked = invocation
|
|
checked.targetHostID = command.hostID
|
|
let denied = planning && CommandRiskClassifier.assess(command.command).risk == .destructive
|
|
var reasons = [
|
|
denied ? "Planning cannot continue a destructive SSH command." : "Write input to: \(command.command)"
|
|
]
|
|
if type == "secret" {
|
|
reasons.append(
|
|
"Consume a one-shot secret handle locally and deliver its bytes only to this command's stdin. "
|
|
+ "The plaintext is intentionally hidden from review; authorization must cover the receiving command, "
|
|
+ "host and use. The command may still echo, store or forward those bytes.")
|
|
}
|
|
return .init(
|
|
invocation: checked, approvalPolicy: denied ? .deny : .ask, reasons: reasons, concurrency: .sequential)
|
|
}
|
|
|
|
public func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
|
|
-> AgentToolResult
|
|
{
|
|
let type = try inputType(invocation)
|
|
let handle = try SSHExecResults.handle(invocation)
|
|
_ = try await background.command(handle, scope: scope)
|
|
let args = try Arguments(invocation)
|
|
let input: SSHCommandInput?
|
|
switch type {
|
|
case "text": input = .text(args.optionalString("stdin")!)
|
|
case "secret":
|
|
let secret = try await context.secretBroker.consume(
|
|
SecretHandle(id: try args.uuid("secret_handle")))
|
|
defer { secret.clear() }
|
|
return Self.result(invocation, try await background.write(handle, scope: scope, input: .secret(secret)))
|
|
default: input = nil
|
|
}
|
|
return Self.result(invocation, try await background.write(handle, scope: scope, input: input))
|
|
}
|
|
|
|
public static func presentArguments(_ input: AgentToolArgumentDetailInput) -> [AgentToolDetail.Item] {
|
|
typealias F = AgentToolDetailFormatting
|
|
var items = SSHExecResults.handleArguments(input)
|
|
if let type = input.arguments["type"]?.stringValue {
|
|
items.append(
|
|
F.field(
|
|
"Input", type == "secret" ? F.localized("Secret", locale: input.locale) : type,
|
|
locale: input.locale))
|
|
}
|
|
if input.arguments["type"]?.stringValue == "text", let text = input.arguments["stdin"]?.stringValue {
|
|
items.append(F.text("Input", text, locale: input.locale))
|
|
}
|
|
return items
|
|
}
|
|
|
|
}
|
|
|
|
nonisolated public struct PlanningSSHExecStdinTool: AgentToolDefinition {
|
|
public static let presenter = SSHExecStdinTool.presenter
|
|
private let base: SSHExecStdinTool
|
|
public init(background: AgentBackgroundCommands, scope: UUID) {
|
|
base = SSHExecStdinTool(background: background, scope: scope, planning: true)
|
|
}
|
|
public var descriptor: AgentToolDescriptor { base.descriptor }
|
|
public func preflight(_ invocation: AgentToolInvocation) async throws -> AgentToolPreflight {
|
|
try await base.preflight(invocation)
|
|
}
|
|
public func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
|
|
-> AgentToolResult
|
|
{
|
|
try await base.execute(invocation, context: context)
|
|
}
|
|
}
|
|
|
|
nonisolated public struct SSHExecCancelTool: AgentToolDefinition, AgentToolSchemaBuilding {
|
|
public static let presenter = AgentToolDetailPresenter(
|
|
id: "builtin.ssh_exec_cancel",
|
|
present: { AgentToolDetailFormatting.genericItems($0.result, locale: $0.locale) },
|
|
presentArguments: SSHExecResults.handleArguments)
|
|
public let background: AgentBackgroundCommands
|
|
public let scope: UUID
|
|
public init(background: AgentBackgroundCommands, scope: UUID) {
|
|
self.background = background
|
|
self.scope = scope
|
|
}
|
|
public var descriptor: AgentToolDescriptor {
|
|
Self.descriptor(
|
|
TermindToolName.sshExecCancel,
|
|
"""
|
|
Cancel the local command task and retire its handle, or release a finished result. \
|
|
Does not send TERM/KILL and does not confirm remote termination. Read output before cancelling. \
|
|
Returns handle, status (cancelled/released), was_running and remote_termination_confirmed=false.
|
|
""",
|
|
properties: ["handle": Self.string(max: 64)], required: ["handle"], target: .host,
|
|
approvalPolicy: .approve,
|
|
presentation: .init(symbol: "stop.circle", activity: .semanticLabel(.remoteCommand), actionKind: .cancel))
|
|
}
|
|
public func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
|
|
-> AgentToolResult
|
|
{
|
|
Self.result(invocation, try await background.cancel(SSHExecResults.handle(invocation), scope: scope))
|
|
}
|
|
}
|
|
|
|
nonisolated public struct SSHExecListTool: AgentToolDefinition, AgentToolSchemaBuilding {
|
|
public static let presenter = AgentToolDetailPresenter(
|
|
id: "builtin.ssh_exec_list",
|
|
present: { AgentToolDetailFormatting.genericItems($0.result, locale: $0.locale) })
|
|
public let background: AgentBackgroundCommands
|
|
public let scope: UUID
|
|
public init(background: AgentBackgroundCommands, scope: UUID) {
|
|
self.background = background
|
|
self.scope = scope
|
|
}
|
|
public var descriptor: AgentToolDescriptor {
|
|
Self.descriptor(
|
|
TermindToolName.sshExecList,
|
|
"""
|
|
List this conversation/session's confirmed commands, newest first. Optional host_id filters by host; \
|
|
include_finished defaults to false. Returns commands with handle, host_id, command, status, stdin_closed, \
|
|
started_at, finished_at, exit_code, signal and error, but no input/output bodies.
|
|
""",
|
|
properties: ["host_id": Self.string(max: 64), "include_finished": Self.boolean()], required: [],
|
|
target: .host, approvalPolicy: .approve, concurrency: .parallel,
|
|
presentation: .init(
|
|
symbol: "list.bullet.rectangle", activity: .semanticLabel(.remoteCommand), actionKind: .list))
|
|
}
|
|
public func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
|
|
-> AgentToolResult
|
|
{
|
|
let args = try Arguments(invocation)
|
|
let hostID = try args.optionalString("host_id").map { _ in try args.uuid("host_id") }
|
|
return Self.result(
|
|
invocation,
|
|
await background.list(
|
|
scope: scope, hostID: hostID, includeFinished: args.optionalBool("include_finished") ?? false))
|
|
}
|
|
|
|
}
|