UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

981 lines (980 loc) 31.5 kB
import { a as asOptionalRecord, r as asNullableRecord } from "./record-coerce-DItp3I4t.js"; import { n as sliceUtf16Safe } from "./utf16-slice-D_ngcYKd.js"; import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js"; import { r as runCommandWithTimeout } from "./exec-BIE-3oLG.js"; import { d as readPositiveIntegerParam } from "./common-Bm6UTDDA.js"; import "./error-runtime-Bz9Tw57Z.js"; import "./string-coerce-runtime-GQa0ehRA.js"; import "./process-runtime-pcN9RjT8.js"; import "./param-readers-BElxWJzG.js"; import "./text-utility-runtime-BjzvUG99.js"; import { t as FILE_TRANSFER_NODE_INVOKE_COMMANDS } from "./node-invoke-policy-commands-DfRVjRLi.js"; import { n as evaluateFilePolicyConstraints, r as persistLiteralGrant, t as evaluateFilePolicy } from "./policy-Cc4oN2HF.js"; import { r as readPathBinding } from "./path-binding-BwFxMbiu.js"; import { t as appendFileTransferAudit } from "./audit-Cw95_l64.js"; import { StringDecoder } from "node:string_decoder"; import crypto from "node:crypto"; //#region extensions/file-transfer/src/shared/append-bounded-text-tail.ts function projectBoundedTextTail(text, maxChars) { return sliceUtf16Safe(text, Math.max(0, text.length - maxChars)); } //#endregion //#region extensions/file-transfer/src/shared/node-invoke-policy-approval.ts function commandKind(command) { return command === "file.write" ? "write" : "read"; } function promptVerb(command) { switch (command) { case "dir.fetch": return "Fetch directory"; case "dir.list": return "List directory"; case "file.write": return "Write file"; case "file.fetch": return "Read file"; } return command; } async function requestApproval(input) { const nodeDisplayName = input.ctx.node?.displayName; const decision = evaluateFilePolicy({ nodeId: input.ctx.nodeId, nodeDisplayName, kind: input.kind, command: input.op, path: input.path, pluginConfig: input.ctx.pluginConfig }); if (decision.ok && decision.reason === "matched-allow") return { ok: true, source: "authored", persist: false, followSymlinks: decision.followSymlinks, maxBytes: decision.maxBytes }; if (decision.ok && decision.reason === "matched-literal") return { ok: true, source: "literal", persist: false, expectedCanonicalPath: decision.expectedCanonicalPath, followSymlinks: decision.followSymlinks, maxBytes: decision.maxBytes }; if (!(decision.ok && decision.reason === "ask-always" || !decision.ok && decision.askable)) { await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.path, decision: !decision.ok && decision.code === "NO_POLICY" ? "denied:no_policy" : "denied:policy", errorCode: decision.ok ? void 0 : decision.code, reason: decision.reason, durationMs: Date.now() - input.startedAt }); return { ok: false, code: decision.ok ? "POLICY_DENIED" : decision.code, message: `${input.op} ${decision.ok ? "POLICY_DENIED" : decision.code}: ${decision.reason}` }; } const approvals = input.ctx.approvals; if (!approvals) { await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.path, decision: "denied:approval", reason: "plugin approvals unavailable", durationMs: Date.now() - input.startedAt }); return { ok: false, code: "APPROVAL_UNAVAILABLE", message: `${input.op} APPROVAL_UNAVAILABLE: plugin approvals unavailable` }; } const verb = promptVerb(input.op); const subject = nodeDisplayName ?? input.ctx.nodeId; const approvalDecision = (await approvals.request({ title: `${verb}: ${input.path}`, description: `${input.op === "dir.fetch" ? `Allow ${verb.toLowerCase()} on ${subject}\nPath: ${input.path}\n\nThis fetch includes descendants of this directory; deny rules still apply.` : `Allow ${verb.toLowerCase()} on ${subject}\nPath: ${input.path}`}\nNode ID: ${input.ctx.nodeId}\n\n"allow-always" saves this exact command and path for this node.`, severity: input.kind === "write" ? "warning" : "info", toolName: input.op })).decision; if (approvalDecision !== "allow-once" && approvalDecision !== "allow-always") { const unavailable = approvalDecision === null || approvalDecision === void 0; const deniedByOperator = approvalDecision === "deny"; const reason = deniedByOperator ? "operator denied" : unavailable ? "no operator available" : "invalid approval decision"; await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.path, decision: "denied:approval", reason, durationMs: Date.now() - input.startedAt }); return { ok: false, code: unavailable ? "APPROVAL_UNAVAILABLE" : "APPROVAL_DENIED", message: unavailable ? `${input.op} APPROVAL_UNAVAILABLE: no operator client connected to approve the request` : deniedByOperator ? `${input.op} APPROVAL_DENIED: operator denied the prompt` : `${input.op} APPROVAL_DENIED: invalid approval decision` }; } await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.path, decision: approvalDecision === "allow-always" ? "allowed:always" : "allowed:once", durationMs: Date.now() - input.startedAt }); return { ok: true, source: "approval", persist: approvalDecision === "allow-always", followSymlinks: decision.followSymlinks ?? false, maxBytes: decision.maxBytes, pendingReapprovalSelector: decision.pendingReapprovalSelector }; } //#endregion //#region extensions/file-transfer/src/shared/node-invoke-policy-params.ts const FILE_FETCH_DEFAULT_MAX_BYTES = 8388608; const FILE_FETCH_HARD_MAX_BYTES = 16777216; const DIR_FETCH_DEFAULT_MAX_BYTES = 8388608; const DIR_FETCH_HARD_MAX_BYTES = 16777216; function readMaxBytes(input) { const requested = (input.value === void 0 ? input.defaultValue : readPositiveIntegerParam({ maxBytes: input.value }, "maxBytes")) ?? input.defaultValue; const clamped = Math.max(1, Math.min(requested, input.hardMax)); return input.policyMax ? Math.min(clamped, input.policyMax) : clamped; } function validateFetchMaxBytesParam(command, params) { if (command !== "file.fetch" && command !== "dir.fetch") return; if (params.maxBytes !== void 0) readPositiveIntegerParam(params, "maxBytes"); } function prepareParams(input) { const next = { ...input.params, followSymlinks: input.followSymlinks }; delete next.preflightOnly; delete next.expectedCanonicalPath; delete next.expectedBinding; if (input.command === "file.fetch") next.maxBytes = readMaxBytes({ value: input.params.maxBytes, defaultValue: FILE_FETCH_DEFAULT_MAX_BYTES, hardMax: FILE_FETCH_HARD_MAX_BYTES, policyMax: input.maxBytes }); else if (input.command === "dir.fetch") next.maxBytes = readMaxBytes({ value: input.params.maxBytes, defaultValue: DIR_FETCH_DEFAULT_MAX_BYTES, hardMax: DIR_FETCH_HARD_MAX_BYTES, policyMax: input.maxBytes }); return next; } //#endregion //#region extensions/file-transfer/src/shared/node-invoke-policy-preflight.ts const DIR_FETCH_MAX_ENTRIES = 5e3; function readResultPayload$1(result) { return asNullableRecord(result.payload); } function joinRemotePolicyPath(root, relPath) { const rel = relPath.replace(/\\/gu, "/").replace(/^\.\//u, ""); if (!rel || rel === ".") return root; const sep = root.includes("\\") && !root.includes("/") ? "\\" : "/"; const prefix = root.replace(/[\\/]$/u, "") || sep; return `${prefix}${prefix.endsWith(sep) ? "" : sep}${rel.split("/").join(sep)}`; } function validateDirFetchPreflightEntry(entry) { if (entry.includes("\0")) return { ok: false, reason: "entry contains NUL byte" }; const normalized = entry.replace(/\\/gu, "/").replace(/^\.\//u, ""); if (!normalized || normalized === ".") return { ok: false, reason: "entry is empty" }; if (normalized.startsWith("/") || /^[A-Za-z]:\//u.test(normalized)) return { ok: false, reason: "entry is absolute" }; if (normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) return { ok: false, reason: "entry contains '..' traversal" }; return { ok: true }; } async function validateDirFetchEntries(input) { const nodeDisplayName = input.ctx.node?.displayName; const missingCode = input.phase === "preflight" ? "PREFLIGHT_ENTRIES_MISSING" : "ARCHIVE_ENTRIES_MISSING"; const invalidCode = input.phase === "preflight" ? "PREFLIGHT_ENTRY_INVALID" : "ARCHIVE_ENTRY_INVALID"; const tooManyCode = input.phase === "preflight" ? "PREFLIGHT_ENTRIES_TOO_MANY" : "ARCHIVE_ENTRIES_TOO_MANY"; if (!Array.isArray(input.entries)) { await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.requestedPath, canonicalPath: input.canonicalPath, decision: "error", errorCode: missingCode, reason: `dir.fetch ${input.phase} did not return entries`, durationMs: Date.now() - input.startedAt }); return policyDeniedResult({ op: input.op, code: missingCode, message: `dir.fetch ${input.phase} did not return entries; refusing archive transfer`, details: { path: input.canonicalPath } }); } if (input.entries.length > 5e3) { const reason = `dir.fetch ${input.phase} contains ${input.entries.length} entries; limit ${DIR_FETCH_MAX_ENTRIES}`; await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.requestedPath, canonicalPath: input.canonicalPath, decision: "denied:policy", errorCode: tooManyCode, reason, durationMs: Date.now() - input.startedAt }); return policyDeniedResult({ op: input.op, code: tooManyCode, message: `${reason}; refusing archive transfer`, details: { path: input.canonicalPath, reason } }); } const entries = []; for (const entry of input.entries) { if (typeof entry !== "string" || entry.length === 0) { await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.requestedPath, canonicalPath: input.canonicalPath, decision: "denied:policy", errorCode: invalidCode, reason: "entry is not a non-empty string", durationMs: Date.now() - input.startedAt }); return policyDeniedResult({ op: input.op, code: invalidCode, message: `directory ${input.phase} entry is invalid: entry is not a non-empty string`, details: { path: input.canonicalPath, reason: "entry is not a non-empty string" } }); } const entryValidation = validateDirFetchPreflightEntry(entry); if (!entryValidation.ok) { const candidate = joinRemotePolicyPath(input.canonicalPath, entry); await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.requestedPath, canonicalPath: candidate, decision: "denied:policy", errorCode: invalidCode, reason: entryValidation.reason, durationMs: Date.now() - input.startedAt }); return policyDeniedResult({ op: input.op, code: invalidCode, message: `directory ${input.phase} entry ${entry} is invalid: ${entryValidation.reason}`, details: { path: candidate, reason: entryValidation.reason } }); } entries.push(entry); } const candidates = [input.canonicalPath, ...entries.map((entry) => joinRemotePolicyPath(input.canonicalPath, entry))]; for (const candidate of candidates) { const policyInput = { nodeId: input.ctx.nodeId, nodeDisplayName, kind: "read", command: "dir.fetch", path: candidate, pluginConfig: input.ctx.pluginConfig }; const policy = input.authorization.source === "authored" ? evaluateFilePolicy(policyInput) : evaluateFilePolicyConstraints(policyInput); if (policy.ok) continue; await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.requestedPath, canonicalPath: candidate, decision: "denied:policy", errorCode: policy.code, reason: policy.reason, durationMs: Date.now() - input.startedAt }); return policyDeniedResult({ op: input.op, code: "PATH_POLICY_DENIED", message: `directory ${input.phase} entry ${candidate} is not allowed by policy: ${policy.reason}`, details: { path: candidate, reason: policy.reason } }); } return null; } function policyDeniedResult(input) { return { ok: false, code: input.code, message: `${input.op} ${input.code}: ${input.message}`, ...input.details ? { details: input.details } : {} }; } async function invokePreflight(input) { const nodeDisplayName = input.ctx.node?.displayName; const preflight = await input.ctx.invokeNode({ params: { ...input.params, preflightOnly: true, ...input.expectedCanonicalPath ? { expectedCanonicalPath: input.expectedCanonicalPath } : {} } }); if (!preflight.ok) { await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.requestedPath, decision: "error", errorCode: preflight.code, errorMessage: preflight.message, durationMs: Date.now() - input.startedAt }); return { ok: false, result: { ok: false, code: preflight.code, message: `${input.op} preflight failed: ${preflight.message}`, details: preflight.details, unavailable: true } }; } const payload = readResultPayload$1(preflight); if (payload?.ok === false) { const code = typeof payload.code === "string" ? payload.code : "PREFLIGHT_FAILED"; const canonicalPath = typeof payload.canonicalPath === "string" ? payload.canonicalPath : void 0; await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.requestedPath, canonicalPath, decision: "error", errorCode: code, errorMessage: typeof payload.message === "string" ? payload.message : void 0, durationMs: Date.now() - input.startedAt }); if (code === "CANONICAL_PATH_CHANGED" && canonicalPath) return { ok: false, result: preflight, canonicalChanged: true, canonicalPath }; return { ok: false, result: preflight }; } const canonicalPath = payload && typeof payload.path === "string" ? payload.path : ""; if (!canonicalPath) return { ok: false, result: policyDeniedResult({ op: input.op, code: "PREFLIGHT_PATH_MISSING", message: "node preflight did not return a canonical path" }) }; const binding = readPathBinding(payload?.binding); const expectedBindingKind = input.op === "file.write" ? "write" : "existing"; if (!binding || binding.kind !== expectedBindingKind) return { ok: false, result: policyDeniedResult({ op: input.op, code: "FILESYSTEM_IDENTITY_MISSING", message: "node preflight did not return a filesystem identity; update the node and retry" }) }; return { ok: true, payload, canonicalPath, binding }; } async function validateCanonicalAuthorization(input) { const nodeDisplayName = input.ctx.node?.displayName; if (input.authorization.source === "literal" && input.authorization.expectedCanonicalPath !== input.canonicalPath) { const approval = await input.ctx.approvals?.request({ title: `${promptVerb(input.op)} target changed: ${input.requestedPath}`, description: `The node now resolves this path to:\n${input.canonicalPath}\n\nApprove this exact canonical target for ${input.ctx.nodeId}.`, severity: input.kind === "write" ? "warning" : "info", toolName: input.op }); if (approval?.decision !== "allow-once" && approval?.decision !== "allow-always") { await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.requestedPath, canonicalPath: input.canonicalPath, decision: "denied:symlink_escape", errorCode: "CANONICAL_PATH_CHANGED", reason: "canonical path differs from the standing approval", durationMs: Date.now() - input.startedAt }); return policyDeniedResult({ op: input.op, code: "CANONICAL_PATH_CHANGED", message: "the canonical path differs from the standing approval and was not reapproved", details: { path: input.canonicalPath } }); } input.authorization.source = "approval"; input.authorization.persist = approval.decision === "allow-always"; input.authorization.expectedCanonicalPath = input.canonicalPath; } const policyInput = { nodeId: input.ctx.nodeId, nodeDisplayName, kind: input.kind, command: input.op, path: input.canonicalPath, pluginConfig: input.ctx.pluginConfig }; const policy = input.authorization.source === "authored" ? evaluateFilePolicy(policyInput) : evaluateFilePolicyConstraints(policyInput); if (policy.ok) return null; await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.requestedPath, canonicalPath: input.canonicalPath, decision: "denied:symlink_escape", errorCode: policy.code, reason: policy.reason, durationMs: Date.now() - input.startedAt }); return policyDeniedResult({ op: input.op, code: "SYMLINK_TARGET_DENIED", message: `requested path resolved to ${input.canonicalPath} which is not allowed by policy` }); } async function invokeAuthorizedPreflight(input) { const expectedCanonicalPath = input.authorization.source === "literal" ? input.authorization.expectedCanonicalPath : void 0; const preflight = await invokePreflight({ ...input, expectedCanonicalPath }); if (preflight.ok || preflight.canonicalChanged !== true) return preflight; const denied = await validateCanonicalAuthorization({ ctx: input.ctx, op: input.op, kind: input.kind, authorization: input.authorization, requestedPath: input.requestedPath, canonicalPath: preflight.canonicalPath, startedAt: input.startedAt }); if (denied) return { ok: false, result: denied }; const retry = await invokePreflight({ ...input, expectedCanonicalPath: input.authorization.expectedCanonicalPath }); if (retry.ok || retry.canonicalChanged !== true) return retry; await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName: input.ctx.node?.displayName, requestedPath: input.requestedPath, canonicalPath: retry.canonicalPath, decision: "denied:symlink_escape", errorCode: "CANONICAL_PATH_CHANGED", reason: "canonical path changed again after reapproval", durationMs: Date.now() - input.startedAt }); return { ok: false, result: policyDeniedResult({ op: input.op, code: "CANONICAL_PATH_CHANGED", message: "the canonical path changed again after reapproval; retry the operation", details: { path: retry.canonicalPath } }) }; } async function runPathPreflight(input) { const preflight = await invokeAuthorizedPreflight(input); if (!preflight.ok) return { ok: false, result: preflight.result }; const denied = await validateCanonicalAuthorization({ ctx: input.ctx, op: input.op, kind: input.kind, authorization: input.authorization, requestedPath: input.requestedPath, canonicalPath: preflight.canonicalPath, startedAt: input.startedAt }); return denied ? { ok: false, result: denied } : { ok: true, canonicalPath: preflight.canonicalPath, binding: preflight.binding }; } async function runDirFetchPreflight(input) { const preflight = await invokeAuthorizedPreflight({ ...input, kind: "read" }); if (!preflight.ok) return { ok: false, result: preflight.result }; const denied = await validateCanonicalAuthorization({ ctx: input.ctx, op: input.op, kind: "read", authorization: input.authorization, requestedPath: input.requestedPath, canonicalPath: preflight.canonicalPath, startedAt: input.startedAt }); if (denied) return { ok: false, result: denied }; const entryDeny = await validateDirFetchEntries({ ctx: input.ctx, op: input.op, authorization: input.authorization, requestedPath: input.requestedPath, canonicalPath: preflight.canonicalPath, entries: preflight.payload?.entries, startedAt: input.startedAt, phase: "preflight" }); return entryDeny ? { ok: false, result: entryDeny } : { ok: true, canonicalPath: preflight.canonicalPath, binding: preflight.binding }; } //#endregion //#region extensions/file-transfer/src/shared/node-invoke-policy.ts const DIR_FETCH_ARCHIVE_LIST_TIMEOUT_MS = 3e4; const DIR_FETCH_ARCHIVE_LIST_MAX_OUTPUT_BYTES = 33554432; const DIR_FETCH_ARCHIVE_LIST_STDERR_TAIL_CHARS = 4096; const DIR_FETCH_ARCHIVE_LIST_ERROR_STDERR_CHARS = 200; function readPath(params) { return typeof params.path === "string" ? params.path : ""; } function readResultPayload(result) { return result.payload && typeof result.payload === "object" && !Array.isArray(result.payload) ? result.payload : null; } function readAuditSizeBytes(command, payload, verifiedDirFetchBytes) { if (command === "dir.fetch") return verifiedDirFetchBytes; if (command === "dir.list") return; return typeof payload?.size === "number" ? payload.size : void 0; } function normalizeTarEntryPath(entry) { const normalized = entry.replace(/\\/gu, "/").replace(/^\.\//u, "").replace(/\/$/u, ""); return normalized.length > 0 ? normalized : null; } async function listDirFetchArchiveEntries(payload) { const tarBase64 = typeof payload?.tarBase64 === "string" ? payload.tarBase64 : ""; if (!tarBase64) return { ok: false, code: "ARCHIVE_ENTRIES_MISSING", reason: "dir.fetch archive did not return tarBase64" }; const tarBuffer = Buffer.from(tarBase64, "base64"); const sizeBytes = tarBuffer.byteLength; if (typeof payload?.tarBytes === "number" && payload.tarBytes !== sizeBytes) return { ok: false, code: "ARCHIVE_SIZE_MISMATCH", reason: `dir.fetch archive size mismatch: payload says ${payload.tarBytes} bytes, decoded ${sizeBytes}` }; const sha256 = crypto.createHash("sha256").update(tarBuffer).digest("hex"); if (typeof payload?.sha256 === "string" && payload.sha256.toLowerCase() !== sha256) return { ok: false, code: "ARCHIVE_INTEGRITY_FAILURE", reason: `dir.fetch archive sha256 mismatch: payload says ${payload.sha256.toLowerCase()}, decoded ${sha256}` }; const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar"; const entries = []; const decoder = new StringDecoder("utf8"); let pending = ""; let outputBytes = 0; let outputTooLarge = false; let entriesTooMany = false; const appendLine = (line) => { const entry = normalizeTarEntryPath(line); if (entry === null) return true; entries.push(entry); entriesTooMany = entries.length > DIR_FETCH_MAX_ENTRIES; return !entriesTooMany; }; const result = await runCommandWithTimeout([ tarBin, "-tzf", "-" ], { input: tarBuffer, maxOutputBytes: { stderr: DIR_FETCH_ARCHIVE_LIST_STDERR_TAIL_CHARS }, onOutputChunk: (chunk, stream) => { if (stream !== "stdout") return true; outputBytes += chunk.byteLength; if (outputBytes > DIR_FETCH_ARCHIVE_LIST_MAX_OUTPUT_BYTES) { outputTooLarge = true; return false; } const lines = `${pending}${decoder.write(chunk)}`.split("\n"); pending = lines.pop() ?? ""; return lines.every(appendLine); }, outputCapture: { stdout: "discard", stderr: "tail" }, tolerateOutputError: { stderr: true }, timeoutMs: DIR_FETCH_ARCHIVE_LIST_TIMEOUT_MS }).catch((error) => ({ error })); if (!("termination" in result)) return { ok: false, code: "ARCHIVE_ENTRIES_UNREADABLE", reason: `tar -tzf error: ${formatErrorMessage(result.error)}` }; if (result.termination === "timeout") return { ok: false, code: "ARCHIVE_ENTRIES_UNREADABLE", reason: "tar -tzf timed out" }; if (entriesTooMany) return { ok: false, code: "ARCHIVE_ENTRIES_TOO_MANY", reason: `dir.fetch archive contains more than ${DIR_FETCH_MAX_ENTRIES} entries` }; if (outputTooLarge) return { ok: false, code: "ARCHIVE_ENTRIES_UNREADABLE", reason: "tar -tzf output too large" }; if (result.termination !== "exit") return { ok: false, code: "ARCHIVE_ENTRIES_UNREADABLE", reason: `tar -tzf error: ${result.termination}` }; if (result.code !== 0) return { ok: false, code: "ARCHIVE_ENTRIES_UNREADABLE", reason: `tar -tzf exited ${result.code}: ${projectBoundedTextTail(result.stderr, DIR_FETCH_ARCHIVE_LIST_ERROR_STDERR_CHARS)}` }; appendLine(pending + decoder.end()); if (entries.length > 5e3) return { ok: false, code: "ARCHIVE_ENTRIES_TOO_MANY", reason: `dir.fetch archive contains more than ${DIR_FETCH_MAX_ENTRIES} entries` }; return { ok: true, entries, sizeBytes, sha256 }; } async function handleFileTransferInvoke(ctx) { if (!FILE_TRANSFER_NODE_INVOKE_COMMANDS.includes(ctx.command)) return { ok: false, code: "UNSUPPORTED_COMMAND", message: "unsupported file-transfer command" }; const command = ctx.command; const op = command; const params = asOptionalRecord(ctx.params) ?? {}; const requestedPath = readPath(params); const nodeDisplayName = ctx.node?.displayName; const startedAt = Date.now(); if (!requestedPath) return { ok: false, code: "INVALID_PARAMS", message: `${op} path required` }; try { validateFetchMaxBytesParam(command, params); } catch (error) { return { ok: false, code: "INVALID_PARAMS", message: error instanceof Error ? error.message : String(error) }; } const gate = await requestApproval({ ctx, op, kind: commandKind(command), path: requestedPath, startedAt }); if (!gate.ok) return { ok: false, code: gate.code, message: gate.message }; let forwardedParams; try { forwardedParams = prepareParams({ command, params, followSymlinks: gate.followSymlinks, maxBytes: gate.maxBytes }); } catch (error) { return { ok: false, code: "INVALID_PARAMS", message: error instanceof Error ? error.message : String(error) }; } let boundCanonicalPath; let boundFilesystemIdentity; if (command === "file.fetch") { const preflight = await runPathPreflight({ ctx, op, kind: "read", authorization: gate, params: forwardedParams, requestedPath, startedAt }); if (!preflight.ok) return preflight.result; boundCanonicalPath = preflight.canonicalPath; boundFilesystemIdentity = preflight.binding; } else if (command === "file.write") { const preflight = await runPathPreflight({ ctx, op, kind: "write", authorization: gate, params: forwardedParams, requestedPath, startedAt }); if (!preflight.ok) return preflight.result; boundCanonicalPath = preflight.canonicalPath; boundFilesystemIdentity = preflight.binding; } else if (command === "dir.fetch") { const preflight = await runDirFetchPreflight({ ctx, op, authorization: gate, params: forwardedParams, requestedPath, startedAt }); if (!preflight.ok) return preflight.result; boundCanonicalPath = preflight.canonicalPath; boundFilesystemIdentity = preflight.binding; } else if (command === "dir.list") { const preflight = await runPathPreflight({ ctx, op, kind: "read", authorization: gate, params: forwardedParams, requestedPath, startedAt }); if (!preflight.ok) return preflight.result; boundCanonicalPath = preflight.canonicalPath; boundFilesystemIdentity = preflight.binding; } if (boundCanonicalPath !== void 0) { forwardedParams.expectedCanonicalPath = boundCanonicalPath; forwardedParams.expectedBinding = boundFilesystemIdentity; } const result = await ctx.invokeNode({ params: forwardedParams }); if (!result.ok) { await appendFileTransferAudit({ op, nodeId: ctx.nodeId, nodeDisplayName, requestedPath, decision: "error", errorCode: result.code, errorMessage: result.message, durationMs: Date.now() - startedAt }); return { ok: false, code: result.code, message: `${op} failed: ${result.message}`, details: result.details, unavailable: true }; } const payload = readResultPayload(result); if (payload?.ok === false) { await appendFileTransferAudit({ op, nodeId: ctx.nodeId, nodeDisplayName, requestedPath, canonicalPath: typeof payload.canonicalPath === "string" ? payload.canonicalPath : void 0, decision: "error", errorCode: typeof payload.code === "string" ? payload.code : void 0, errorMessage: typeof payload.message === "string" ? payload.message : void 0, durationMs: Date.now() - startedAt }); return result; } const canonicalPath = payload && typeof payload.path === "string" ? payload.path : ""; if (!canonicalPath) return policyDeniedResult({ op, code: "CANONICAL_PATH_MISSING", message: "node result did not return a canonical path" }); if (boundCanonicalPath !== void 0 && boundCanonicalPath !== canonicalPath) return policyDeniedResult({ op, code: "CANONICAL_PATH_CHANGED", message: "the canonical path changed after preflight; refusing the result", details: { path: canonicalPath } }); const canonicalDeny = await validateCanonicalAuthorization({ ctx, op, kind: commandKind(command), authorization: gate, requestedPath, canonicalPath, startedAt }); if (canonicalDeny) return canonicalDeny; let verifiedDirFetchArchive; if (command === "dir.fetch") { const archiveEntries = await listDirFetchArchiveEntries(payload); if (!archiveEntries.ok) { await appendFileTransferAudit({ op, nodeId: ctx.nodeId, nodeDisplayName, requestedPath, canonicalPath, decision: "error", errorCode: archiveEntries.code, reason: archiveEntries.reason, durationMs: Date.now() - startedAt }); return policyDeniedResult({ op, code: archiveEntries.code, message: `${archiveEntries.reason}; refusing archive transfer`, details: { path: canonicalPath, reason: archiveEntries.reason } }); } const archiveDeny = await validateDirFetchEntries({ ctx, op, authorization: gate, requestedPath, canonicalPath, entries: archiveEntries.entries, startedAt, phase: "archive" }); if (archiveDeny) return archiveDeny; verifiedDirFetchArchive = { sizeBytes: archiveEntries.sizeBytes, sha256: archiveEntries.sha256 }; } let standingApprovalWarning; if (gate.persist) try { await persistLiteralGrant({ nodeId: ctx.nodeId, command, requestedPath, canonicalPath, pendingReapprovalSelector: gate.pendingReapprovalSelector }); } catch (error) { standingApprovalWarning = "The transfer succeeded, but the standing approval was not saved. Run the command again and choose allow-always, or use allow-once."; await appendFileTransferAudit({ op, nodeId: ctx.nodeId, nodeDisplayName, requestedPath, canonicalPath, decision: "error", errorCode: "APPROVAL_PERSIST_FAILED", reason: `standing approval persistence failed: ${String(error)}`, durationMs: Date.now() - startedAt }); } await appendFileTransferAudit({ op, nodeId: ctx.nodeId, nodeDisplayName, requestedPath, canonicalPath, decision: "allowed", sizeBytes: readAuditSizeBytes(command, payload, verifiedDirFetchArchive?.sizeBytes), sha256: command === "dir.fetch" ? verifiedDirFetchArchive?.sha256 : typeof payload?.sha256 === "string" ? payload.sha256 : void 0, durationMs: Date.now() - startedAt }); return standingApprovalWarning && payload ? { ok: true, payload: { ...payload, standingApprovalWarning } } : result; } function createFileTransferNodeInvokePolicy() { return { commands: [...FILE_TRANSFER_NODE_INVOKE_COMMANDS], handle: handleFileTransferInvoke }; } //#endregion export { createFileTransferNodeInvokePolicy };