Files

692 lines
29 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
nonisolated public protocol SFTPToolDefinition: AgentToolDefinition, AgentToolSchemaBuilding {
static var name: String { get }
static var summary: String { get }
static var approvalPolicy: AgentToolDescriptor.ApprovalPolicy { get }
static var concurrency: AgentToolDescriptor.Concurrency { get }
static var extraProperties: [String: AgentJSONValue] { get }
static var extraRequired: [String] { get }
static var presentation: AgentToolDescriptor.Presentation { get }
static var schedulesDataTransfer: Bool { get }
var authentication: AgentSSHAuthenticationRegistry { get }
var connections: AgentConnectionPool { get }
func estimatedByteCount(arguments: Arguments) throws -> Int64?
func perform(
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult
}
nonisolated extension SFTPToolDefinition {
public static var concurrency: AgentToolDescriptor.Concurrency {
approvalPolicy == .approve ? .parallel : .sequential
}
public static var extraProperties: [String: AgentJSONValue] { [:] }
public static var extraRequired: [String] { [] }
public static var schedulesDataTransfer: Bool { false }
public func estimatedByteCount(arguments _: Arguments) throws -> Int64? { nil }
public var descriptor: AgentToolDescriptor {
var properties: [String: AgentJSONValue] = [
"host_id": Self.string(), "path": Self.string(max: 4_096),
]
properties.merge(Self.extraProperties) { _, new in new }
return Self.descriptor(
Self.name, Self.summary, properties: properties,
required: ["host_id", "path"] + Self.extraRequired,
target: .host, approvalPolicy: Self.approvalPolicy, concurrency: Self.concurrency,
presentation: Self.presentation
)
}
public func preflight(_ invocation: AgentToolInvocation) async throws -> AgentToolPreflight {
var checked = invocation
let arguments = try Arguments(invocation)
checked.targetHostID = try arguments.uuid("host_id")
_ = try AgentToolSupport.normalizeRemotePath(arguments.string("path"))
return AgentToolPreflight(
invocation: checked,
approvalPolicy: Self.approvalPolicy
)
}
public func execute(
_ invocation: AgentToolInvocation, context: AgentToolExecutionContext
) async throws -> AgentToolResult {
let arguments = try Arguments(invocation)
let hostID = try arguments.uuid("host_id")
let path = try AgentToolSupport.normalizeRemotePath(arguments.string("path"))
return try await AgentToolSupport.withAuthenticatedSFTP(
hostID: hostID, invocation: invocation, context: context,
authentication: authentication, pool: connections,
byteCount: try estimatedByteCount(arguments: arguments),
schedulesDataTransfer: Self.schedulesDataTransfer
) { client in
try await perform(
client: client, path: path, arguments: arguments, invocation: invocation
)
}
}
}
nonisolated public struct SFTPListTool: SFTPToolDefinition {
public static let name = "sftp_list"
public static let summary = "List a remote directory."
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .approve
public static let presenter = AgentToolDetailPresenter(
id: "builtin.sftp_list", present: present
)
public static let presentation = AgentToolDescriptor.Presentation(
symbol: "folder", activity: .semanticArgument(key: "path", fallback: .remoteDirectory),
output: .automatic, actionKind: .list
)
public let authentication: AgentSSHAuthenticationRegistry
public let connections: AgentConnectionPool
public func perform(
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult {
let files = try await client.listDirectory(at: path)
return Self.result(invocation, .array(files.map(AgentToolSupport.remoteFileJSON)))
}
public init(authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool) {
self.authentication = authentication
self.connections = connections
}
}
nonisolated public struct SFTPStatTool: SFTPToolDefinition {
public static let name = "sftp_stat"
public static let summary = "Read remote file metadata."
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .approve
public static let presenter = AgentToolDetailPresenter(
id: "builtin.sftp_stat", present: present
)
public static let presentation = AgentToolDescriptor.Presentation(
symbol: "info.circle", activity: .semanticArgument(key: "path", fallback: .remoteFile),
output: .automatic, actionKind: .inspect
)
public let authentication: AgentSSHAuthenticationRegistry
public let connections: AgentConnectionPool
public func perform(
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult {
guard let file = try await client.attributes(at: path) else { throw AgentToolError.notFound }
return Self.result(invocation, AgentToolSupport.remoteFileJSON(file))
}
public init(authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool) {
self.authentication = authentication
self.connections = connections
}
}
nonisolated public struct SFTPReadTool: SFTPToolDefinition {
public static let name = "sftp_read"
public static let summary =
"Read a UTF-8 remote file by lines (offset starts at 1). Pages contain at most 2,000 lines or 64 KiB. Continue at next_offset."
public static let extraProperties = [
"offset": integer(min: 1, max: 10_000_000), "limit": integer(min: 1, max: 2_000),
]
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .approve
public static let schedulesDataTransfer = true
public static let presenter = AgentToolDetailPresenter(
id: "builtin.sftp_read", layout: .compact, present: present
)
public static let presentation = AgentToolDescriptor.Presentation(
symbol: "doc.text", activity: .semanticArgument(key: "path", fallback: .remoteFile),
output: .automatic, actionKind: .read
)
public let authentication: AgentSSHAuthenticationRegistry
public let connections: AgentConnectionPool
public func estimatedByteCount(arguments _: Arguments) throws -> Int64? { Int64(AgentTruncateLimits.maximumBytes) }
public func perform(
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult {
let page = try await client.readTextPage(
at: path, offset: arguments.optionalInt("offset") ?? 1,
limit: arguments.optionalInt("limit") ?? AgentTextPageReader.defaultLineLimit
)
var result = Self.result(
invocation,
.object([
"path": .string(path), "content": .string(page.content),
"bytes": .number(Double(page.content.utf8.count)),
"offset": .number(Double(page.offset)), "returned_lines": .number(Double(page.returnedLines)),
"next_offset": page.nextOffset.map { .number(Double($0)) } ?? .null,
"eof": .bool(!page.isTruncated),
]))
result.hasBoundedModelContent = true
return result
}
public init(authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool) {
self.authentication = authentication
self.connections = connections
}
}
nonisolated public struct SFTPWriteTool: SFTPToolDefinition {
public static let name = "sftp_write"
public static let summary = "Write a bounded UTF-8 remote file atomically."
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .ask
public static let schedulesDataTransfer = true
public static let extraProperties = ["content": string(max: 131_072), "overwrite": boolean()]
public static let extraRequired = ["content"]
public static let presenter = AgentToolDetailPresenter(
id: "builtin.sftp_write", present: present,
presentArguments: presentArguments
)
public static let presentation = AgentToolDescriptor.Presentation(
symbol: "square.and.pencil",
activity: .semanticArgument(key: "path", fallback: .remoteFile),
output: .automatic, actionKind: .write
)
public let authentication: AgentSSHAuthenticationRegistry
public let connections: AgentConnectionPool
public func estimatedByteCount(arguments: Arguments) throws -> Int64? {
Int64(try arguments.string("content").utf8.count)
}
public func perform(
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult {
try await client.writeFile(
Data(try arguments.string("content").utf8), at: path,
overwrite: arguments.optionalBool("overwrite") ?? false)
return Self.result(invocation, .object(["path": .string(path), "written": .bool(true)]))
}
public init(authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool) {
self.authentication = authentication
self.connections = connections
}
}
nonisolated public struct SFTPMkdirTool: SFTPToolDefinition {
public static let name = "sftp_mkdir"
public static let summary = "Create a remote directory."
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .ask
public static let presenter = AgentToolDetailPresenter(
id: "builtin.sftp_mkdir", present: present,
presentArguments: presentArguments
)
public static let presentation = AgentToolDescriptor.Presentation(
symbol: "folder.badge.plus",
activity: .semanticArgument(key: "path", fallback: .remoteDirectory),
output: .automatic, actionKind: .create
)
public let authentication: AgentSSHAuthenticationRegistry
public let connections: AgentConnectionPool
public func perform(
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult {
try await client.createDirectory(at: path)
return Self.result(invocation, .object(["path": .string(path), "created": .bool(true)]))
}
public init(authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool) {
self.authentication = authentication
self.connections = connections
}
}
nonisolated public struct SFTPMoveTool: SFTPToolDefinition {
public static let name = "sftp_move"
public static let summary = "Move or rename a remote path."
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .ask
public static let extraProperties = ["destination": string(max: 4_096), "overwrite": boolean()]
public static let extraRequired = ["destination"]
public static let presenter = AgentToolDetailPresenter(
id: "builtin.sftp_move", present: present,
presentArguments: presentArguments
)
public static let presentation = AgentToolDescriptor.Presentation(
symbol: "arrow.right", activity: .semanticArgument(key: "path", fallback: .remoteFile),
output: .automatic, actionKind: .move
)
public let authentication: AgentSSHAuthenticationRegistry
public let connections: AgentConnectionPool
public func perform(
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult {
let destination = try AgentToolSupport.normalizeRemotePath(arguments.string("destination"))
try await client.rename(
from: path, to: destination,
overwrite: arguments.optionalBool("overwrite") ?? false)
return Self.result(
invocation,
.object([
"source": .string(path), "destination": .string(destination), "moved": .bool(true),
]))
}
public init(authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool) {
self.authentication = authentication
self.connections = connections
}
}
nonisolated public struct SFTPChmodTool: SFTPToolDefinition {
public static let name = "sftp_chmod"
public static let summary = "Change remote POSIX permissions."
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .ask
public static let extraProperties = ["mode": string(max: 4)]
public static let extraRequired = ["mode"]
public static let presenter = AgentToolDetailPresenter(
id: "builtin.sftp_chmod", present: present,
presentArguments: presentArguments
)
public static let presentation = AgentToolDescriptor.Presentation(
symbol: "lock", activity: .semanticArgument(key: "path", fallback: .remoteFile),
output: .automatic, actionKind: .changePermissions
)
public let authentication: AgentSSHAuthenticationRegistry
public let connections: AgentConnectionPool
public func perform(
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult {
let raw = try arguments.string("mode")
guard (3...4).contains(raw.count), raw.allSatisfy({ ("0"..."7").contains(String($0)) }),
let mode = UInt32(raw, radix: 8)
else {
throw AgentToolError.invalidArguments("mode must be a 3- or 4-digit octal value.")
}
try await client.setPermissions(at: path, to: mode)
return Self.result(invocation, .object(["path": .string(path), "mode": .string(raw)]))
}
public init(authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool) {
self.authentication = authentication
self.connections = connections
}
}
nonisolated public struct SFTPDeleteTool: SFTPToolDefinition {
public static let name = "sftp_delete"
public static let summary = "Permanently delete one remote file or empty directory."
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .ask
public static let extraProperties = ["kind": enumeration(["file", "directory"])]
public static let extraRequired = ["kind"]
public static let presenter = AgentToolDetailPresenter(
id: "builtin.sftp_delete", present: present,
presentArguments: presentArguments
)
public static let presentation = AgentToolDescriptor.Presentation(
symbol: "trash", activity: .semanticArgument(key: "path", fallback: .remoteFile),
output: .automatic, actionKind: .delete
)
public let authentication: AgentSSHAuthenticationRegistry
public let connections: AgentConnectionPool
public func perform(
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult {
if try arguments.string("kind") == "directory" {
try await client.removeDirectory(at: path)
} else {
try await client.removeFile(at: path)
}
return Self.result(invocation, .object(["path": .string(path), "deleted": .bool(true)]))
}
public init(authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool) {
self.authentication = authentication
self.connections = connections
}
}
nonisolated extension SFTPListTool {
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
return F.arrayItems(input) { values in
let rows = values.compactMap {
SFTPToolPresentation.remoteFileRow($0, locale: input.locale)
}
return [
.message(String(localized: "\(rows.count) items", locale: input.locale), .secondary),
.list(
.init(
title: nil, rows: rows,
emptyMessage: F.localized("Directory is empty", locale: input.locale)
)),
]
}
}
}
nonisolated extension SFTPStatTool {
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
return F.objectItems(input) { object in
var items: [AgentToolDetail.Item] = []
F.appendField(
&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Kind",
object["kind"]?.stringValue.map {
F.kindLabel($0, locale: input.locale)
}, locale: input.locale)
if let bytes = object["size"]?.integerValue {
items.append(F.field("Size", F.byteCount(bytes), locale: input.locale))
}
F.appendField(
&items, "Permissions", object["permissions"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Modified", object["modified_at"]?.stringValue.map(F.displayDate),
locale: input.locale)
return items
}
}
}
nonisolated extension SFTPReadTool {
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
return F.objectItems(input) { object in
var items: [AgentToolDetail.Item] = []
F.appendField(
&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
let content = object["content"]?.stringValue ?? ""
items.append(
content.isEmpty
? .message(F.localized("Empty file", locale: input.locale), .secondary)
: .text(
.init(
title: F.localized("Content", locale: input.locale),
text: content, style: .monospaced)))
if let bytes = object["bytes"]?.integerValue {
items.append(F.field("Size", F.byteCount(bytes), locale: input.locale))
}
return items
}
}
}
nonisolated extension SFTPWriteTool {
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
return F.objectItems(input) { object in
var items: [AgentToolDetail.Item] = []
F.appendField(
&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
if let content = input.arguments["content"]?.stringValue {
items.append(F.field("Size", F.byteCount(content.utf8.count), locale: input.locale))
}
items.append(
F.field(
"Result", F.localized("Written", locale: input.locale),
locale: input.locale))
return items
}
}
public static func presentArguments(
_ input: AgentToolArgumentDetailInput
) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
var items = SFTPToolPresentation.pathArguments(input)
if let content = input.arguments["content"]?.stringValue {
items.append(F.text("Content", content, locale: input.locale))
}
SFTPToolPresentation.appendBoolean(
&items, label: "Overwrite", value: input.arguments["overwrite"],
locale: input.locale)
return items
}
}
nonisolated extension SFTPMkdirTool {
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
SFTPToolPresentation.pathDetail(
input,
result: AgentToolDetailFormatting.localized(
"Directory created", locale: input.locale
)
)
}
public static func presentArguments(
_ input: AgentToolArgumentDetailInput
) -> [AgentToolDetail.Item] {
SFTPToolPresentation.pathArguments(input)
}
}
nonisolated extension SFTPMoveTool {
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
return F.objectItems(input) { object in
var items: [AgentToolDetail.Item] = []
F.appendField(
&items, "Source", object["source"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Destination", object["destination"]?.stringValue,
locale: input.locale, monospaced: true)
return items
}
}
public static func presentArguments(
_ input: AgentToolArgumentDetailInput
) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
var items: [AgentToolDetail.Item] = []
F.appendField(
&items, "Source", input.arguments["path"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Destination", input.arguments["destination"]?.stringValue,
locale: input.locale, monospaced: true)
SFTPToolPresentation.appendBoolean(
&items, label: "Overwrite", value: input.arguments["overwrite"],
locale: input.locale)
return items
}
}
nonisolated extension SFTPChmodTool {
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
return F.objectItems(input) { object in
var items: [AgentToolDetail.Item] = []
F.appendField(
&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Permissions", object["mode"]?.stringValue,
locale: input.locale, monospaced: true)
return items
}
}
public static func presentArguments(
_ input: AgentToolArgumentDetailInput
) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
var items = SFTPToolPresentation.pathArguments(input)
F.appendField(
&items, "Permissions", input.arguments["mode"]?.stringValue,
locale: input.locale, monospaced: true)
return items
}
}
nonisolated extension SFTPDeleteTool {
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
return F.objectItems(input) { object in
var items: [AgentToolDetail.Item] = []
F.appendField(
&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Kind",
input.arguments["kind"]?.stringValue.map {
F.kindLabel($0, locale: input.locale)
}, locale: input.locale)
items.append(
F.field(
"Result", F.localized("Deleted", locale: input.locale),
locale: input.locale))
return items
}
}
public static func presentArguments(
_ input: AgentToolArgumentDetailInput
) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
var items = SFTPToolPresentation.pathArguments(input)
F.appendField(
&items, "Kind",
input.arguments["kind"]?.stringValue.map {
F.kindLabel($0, locale: input.locale)
}, locale: input.locale)
return items
}
}
nonisolated public enum SFTPToolPresentation {
public static func pathArguments(
_ input: AgentToolArgumentDetailInput
) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
var items: [AgentToolDetail.Item] = []
F.appendField(
&items, "Path", input.arguments["path"]?.stringValue,
locale: input.locale, monospaced: true)
return items
}
public static func appendBoolean(
_ items: inout [AgentToolDetail.Item],
label: String.LocalizationValue,
value: AgentJSONValue?,
locale: Locale
) {
guard let value = value?.boolValue else { return }
items.append(
AgentToolDetailFormatting.field(
label,
AgentToolDetailFormatting.localized(value ? "Yes" : "No", locale: locale),
locale: locale
))
}
public static func remoteFileRow(
_ value: AgentJSONValue, locale: Locale
) -> AgentToolDetail.ListRow? {
typealias F = AgentToolDetailFormatting
guard let object = value.objectValue else { return nil }
let path = object["path"]?.stringValue ?? ""
let name =
F.nonempty(object["name"]?.stringValue)
?? F.nonempty(path.split(separator: "/").last.map(String.init))
?? F.localized("Unnamed item", locale: locale)
let kind = object["kind"]?.stringValue ?? ""
var metadata = [F.kindLabel(kind, locale: locale)]
if let bytes = object["size"]?.integerValue { metadata.append(F.byteCount(bytes)) }
if let permissions = F.nonempty(object["permissions"]?.stringValue) {
metadata.append(permissions)
}
return .init(
title: name, subtitle: metadata.filter { !$0.isEmpty }.joined(separator: " · "),
detail: object["modified_at"]?.stringValue.map(F.displayDate),
symbol: kind == "directory" ? "folder" : "doc"
)
}
public static func pathDetail(
_ input: AgentToolDetailInput, result: String
) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
return F.objectItems(input) { object in
var items: [AgentToolDetail.Item] = []
F.appendField(
&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
items.append(F.field("Result", result, locale: input.locale))
return items
}
}
}
nonisolated extension AgentToolSupport {
/// One authenticated SFTP operation. Connection setup may safely retry
/// after a rejected credential because the operation has not begun. Once
/// the client is handed to `operation`, a lost connection is only evicted:
/// replaying a move, delete or write would have unknowable side effects.
public static func withAuthenticatedSFTP<Value: Sendable>(
hostID: Host.ID, invocation: AgentToolInvocation,
context: AgentToolExecutionContext,
authentication: AgentSSHAuthenticationRegistry, pool: AgentConnectionPool,
byteCount: Int64? = nil,
schedulesDataTransfer: Bool = false,
operation: @escaping @Sendable (any SFTPChannelProtocol) async throws -> Value
) async throws -> Value {
try await authentication.withSFTP(
hostID: hostID, invocationID: invocation.id,
interaction: context.sshInteraction, pool: pool,
runID: context.runID, byteCount: byteCount,
schedulesDataTransfer: schedulesDataTransfer
) { client in
do {
return try await operation(client)
} catch let error as SFTPError {
switch error {
case .notConnected, .connectionLost:
await pool.disconnect(hostID: hostID)
case .cancelled, .localFile, .operation:
break
}
throw error
}
}
}
/// Internal rather than private because `scratch_pull` and `scratch_push` aim at
/// the same remote paths the SFTP tools do, and two implementations of "what is
/// a valid remote path" would be one too many.
public static func normalizeRemotePath(_ raw: String) throws -> String {
guard raw.hasPrefix("/"), !raw.contains("\0"), !raw.contains("\r"), !raw.contains("\n") else {
throw AgentToolError.invalidPath
}
var components: [Substring] = []
for component in raw.split(separator: "/") {
if component == "." { continue }
if component == ".." {
guard !components.isEmpty else { throw AgentToolError.invalidPath }
components.removeLast()
} else {
components.append(component)
}
}
return "/" + components.joined(separator: "/")
}
fileprivate static func remoteFileJSON(_ file: RemoteFile) -> AgentJSONValue {
.object([
"path": .string(file.path), "name": .string(file.name),
"kind": .string(file.kind.rawValue), "size": .number(Double(file.size)),
"permissions": .string(file.octalPermissionDescription),
"modified_at": file.modifiedAt.map { .string($0.ISO8601Format()) } ?? .null,
])
}
}