UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

984 lines (983 loc) 31.3 kB
import { i as getRuntimeConfig } from "./io-Gi7-pyU-.js"; import { n as mutateConfigFile } from "./config-C9RxTsn1.js"; import { g as readPositiveIntegerParam } from "./common-C4yy9V-D.js"; import "./runtime-config-snapshot-CDoFLjqb.js"; import "./config-mutation-B38RpU_9.js"; import "./param-readers-HkqGooLN.js"; import { t as FILE_TRANSFER_NODE_INVOKE_COMMANDS } from "./node-invoke-policy-commands-DfRVjRLi.js"; import { t as appendFileTransferAudit } from "./audit-BixB55LQ.js"; import path from "node:path"; import os from "node:os"; import { spawn } from "node:child_process"; import { minimatch } from "minimatch"; //#region extensions/file-transfer/src/shared/policy.ts function asFilePolicyConfig(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return null; return value; } function readFilePolicyConfigFromPluginConfig(pluginConfig) { if (!pluginConfig || typeof pluginConfig !== "object" || Array.isArray(pluginConfig)) return null; const nodes = pluginConfig.nodes; return asFilePolicyConfig(nodes); } function readPluginConfigFromRuntimeConfig() { const plugins = getRuntimeConfig().plugins; if (!plugins || typeof plugins !== "object") return null; const entries = plugins.entries; if (!entries || typeof entries !== "object") return null; const entry = entries["file-transfer"]; if (!entry || typeof entry !== "object") return null; const pluginConfig = entry.config; return pluginConfig && typeof pluginConfig === "object" && !Array.isArray(pluginConfig) ? pluginConfig : null; } function readFilePolicyConfig(pluginConfig) { return readFilePolicyConfigFromPluginConfig(readPluginConfigFromRuntimeConfig()) ?? readFilePolicyConfigFromPluginConfig(pluginConfig); } function expandTilde(p) { if (p.startsWith("~/") || p === "~") return path.join(os.homedir(), p.slice(p === "~" ? 1 : 2)); return p; } function normalizeGlobs(patterns) { if (!Array.isArray(patterns)) return []; return patterns.filter((p) => typeof p === "string" && p.trim().length > 0).map((p) => expandTilde(p.trim())); } function matchesAny(target, patterns) { const normalizedTarget = target.replace(/\\/gu, "/"); for (const pattern of patterns) { const normalizedPattern = pattern.replace(/\\/gu, "/"); if (minimatch(target, pattern, { dot: true }) || minimatch(normalizedTarget, normalizedPattern, { dot: true })) return true; } return false; } function resolveNodePolicy(config, nodeId, nodeDisplayName) { const candidates = [nodeId, nodeDisplayName].filter((k) => typeof k === "string" && k.length > 0); for (const key of candidates) if (config[key]) return { key, entry: config[key] }; if (config["*"]) return { key: "*", entry: config["*"] }; return null; } function normalizeAskMode(value) { if (value === "on-miss" || value === "always" || value === "off") return value; return "off"; } /** * Evaluate whether (nodeId, kind, path) is permitted. * * Resolution order: * 1. No file-transfer config or no entry for this node → NO_POLICY (deny, * not askable — operator hasn't opted in at all). * 2. denyPaths matches → POLICY_DENIED, not askable (hard deny). * 3. ask=always → ask-always (prompt every time). * 4. allowPaths matches → matched-allow (silent allow). * 5. ask=on-miss → POLICY_DENIED with askable=true. * 6. ask=off (or unset) → POLICY_DENIED, not askable. */ /** * Reject any path whose RAW string contains a ".." segment. Checking the * raw string (not the normalized form) is the point — `posix.normalize` * collapses "/allowed/../etc/passwd" to "/etc/passwd", which would defeat * the check. We want to flag the literal traversal sequence the agent * passed in, before any glob match runs. * * Without this, "/allowed/../etc/passwd" matches the glob "/allowed/**" * pre-realpath, so the node fetches the bytes before the post-flight * canonical-path check denies — too late, the bytes already crossed the * node→gateway boundary. * * Treats backslash and forward slash as equivalent separators so a Windows * node can't be hit with "C:\\allowed\\..\\Windows\\system.ini". */ function containsParentRefSegment(p) { return p.replace(/\\/gu, "/").split("/").includes(".."); } function evaluateFilePolicy(input) { if (containsParentRefSegment(input.path)) return { ok: false, code: "POLICY_DENIED", reason: "path contains '..' segments; reject before glob match", askable: false }; const config = readFilePolicyConfig(input.pluginConfig); if (!config) return { ok: false, code: "NO_POLICY", reason: "no plugins.entries.file-transfer.config.nodes config; file-transfer is deny-by-default until configured", askable: false }; const resolved = resolveNodePolicy(config, input.nodeId, input.nodeDisplayName); if (!resolved) return { ok: false, code: "NO_POLICY", reason: `no file-transfer policy entry for "${input.nodeDisplayName ?? input.nodeId}"; configure plugins.entries.file-transfer.config.nodes or "*"`, askable: false }; const nodeConfig = resolved.entry; const askMode = normalizeAskMode(nodeConfig.ask); const maxBytes = typeof nodeConfig.maxBytes === "number" && Number.isFinite(nodeConfig.maxBytes) ? Math.max(1, Math.floor(nodeConfig.maxBytes)) : void 0; const followSymlinks = nodeConfig.followSymlinks === true; const denyPatterns = normalizeGlobs(nodeConfig.denyPaths); if (matchesAny(input.path, denyPatterns)) return { ok: false, code: "POLICY_DENIED", reason: "path matches a denyPaths pattern", askable: false, askMode, maxBytes, followSymlinks }; if (askMode === "always") return { ok: true, reason: "ask-always", askMode, maxBytes, followSymlinks }; const allowPatterns = input.kind === "read" ? normalizeGlobs(nodeConfig.allowReadPaths) : normalizeGlobs(nodeConfig.allowWritePaths); if (allowPatterns.length > 0 && matchesAny(input.path, allowPatterns)) return { ok: true, reason: "matched-allow", maxBytes, followSymlinks }; if (askMode === "on-miss") return { ok: false, code: "POLICY_DENIED", reason: `path does not match any allow${input.kind === "read" ? "Read" : "Write"}Paths pattern`, askable: true, askMode, maxBytes, followSymlinks }; return { ok: false, code: "POLICY_DENIED", reason: allowPatterns.length === 0 ? `no allow${input.kind === "read" ? "Read" : "Write"}Paths configured` : `path does not match any allow${input.kind === "read" ? "Read" : "Write"}Paths pattern`, askable: false, askMode, maxBytes, followSymlinks }; } /** * Persist an "allow-always" approval by appending the path to the * relevant allowReadPaths / allowWritePaths list for the node. Uses * mutateConfigFile so the change survives gateway restarts. * * Inserts under whichever key matched the policy (per-node entry, or * the "*" wildcard if that's what was hit). If no entry exists yet, * creates one keyed by nodeDisplayName ?? nodeId. */ /** * Reject special object keys that would mutate the prototype chain when * used as a property name (e.g. `__proto__` setter on a plain object). * The nodeDisplayName comes from paired-node metadata which we don't * fully control; refuse to persist policy under a key that could corrupt * the plugin policy container's prototype. */ function assertSafeConfigKey(key) { if (key === "__proto__" || key === "prototype" || key === "constructor") throw new Error(`refusing to persist file-transfer policy under unsafe key: ${key}`); return key; } async function persistAllowAlways(input) { const field = input.kind === "read" ? "allowReadPaths" : "allowWritePaths"; await mutateConfigFile({ afterWrite: { mode: "none", reason: "file-transfer allow-always policy update" }, mutate: (draft) => { const root = draft; const plugins = root.plugins ??= {}; const entries = plugins.entries ??= {}; const pluginEntry = entries["file-transfer"] ??= {}; const pluginConfig = pluginEntry.config ??= {}; const fileTransfer = pluginConfig.nodes ??= {}; let key = [input.nodeId, input.nodeDisplayName].filter((k) => typeof k === "string" && k.length > 0).find((c) => Object.hasOwn(fileTransfer, c)); if (!key) { key = assertSafeConfigKey(input.nodeDisplayName ?? input.nodeId); fileTransfer[key] = {}; } const entry = fileTransfer[key]; const list = Array.isArray(entry[field]) ? entry[field] : []; if (!list.includes(input.path)) list.push(input.path); entry[field] = list; } }); } //#endregion //#region extensions/file-transfer/src/shared/node-invoke-policy.ts const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024; const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024; const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024; const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024; const DIR_FETCH_MAX_ENTRIES = 5e3; const DIR_FETCH_ARCHIVE_LIST_TIMEOUT_MS = 3e4; const DIR_FETCH_ARCHIVE_LIST_MAX_OUTPUT_BYTES = 32 * 1024 * 1024; const DIR_FETCH_ARCHIVE_LIST_STDERR_TAIL_CHARS = 4096; function asRecord(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } function appendBoundedTextTail(current, chunk, maxChars) { const next = current + chunk.toString(); return next.length > maxChars ? next.slice(-maxChars) : next; } function readPath(params) { return typeof params.path === "string" ? params.path.trim() : ""; } 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 commandKind(command) { return command === "file.write" ? "write" : "read"; } function validateFetchMaxBytesParam(command, params) { if (command !== "file.fetch" && command !== "dir.fetch") return; if (params.maxBytes !== void 0) readPositiveIntegerParam(params, "maxBytes"); } 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, path: input.path, pluginConfig: input.ctx.pluginConfig }); if (decision.ok && decision.reason === "matched-allow") return { ok: true, 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.ok ? decision.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 approval = await approvals.request({ title: `${verb}: ${input.path}`, description: `Allow ${verb.toLowerCase()} on ${subject}\nPath: ${input.path}\nKind: ${input.kind}\n\n"allow-always" appends this exact path to allow${input.kind === "read" ? "Read" : "Write"}Paths.`, severity: input.kind === "write" ? "warning" : "info", toolName: input.op }); if (approval.decision === "deny" || approval.decision === null || !approval.decision) { await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.path, decision: "denied:approval", reason: approval.decision === "deny" ? "operator denied" : "no operator available", durationMs: Date.now() - input.startedAt }); return { ok: false, code: approval.decision === "deny" ? "APPROVAL_DENIED" : "APPROVAL_UNAVAILABLE", message: approval.decision === "deny" ? `${input.op} APPROVAL_DENIED: operator denied the prompt` : `${input.op} APPROVAL_UNAVAILABLE: no operator client connected to approve the request` }; } if (approval.decision === "allow-always") try { await persistAllowAlways({ nodeId: input.ctx.nodeId, nodeDisplayName, kind: input.kind, path: input.path }); const refreshed = evaluateFilePolicy({ nodeId: input.ctx.nodeId, nodeDisplayName, kind: input.kind, path: input.path, pluginConfig: input.ctx.pluginConfig }); if (refreshed.ok) { await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.path, decision: "allowed:always", durationMs: Date.now() - input.startedAt }); return { ok: true, followSymlinks: refreshed.followSymlinks, maxBytes: refreshed.maxBytes }; } } catch (error) { await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.path, decision: "allowed:always", reason: `persist failed: ${String(error)}`, durationMs: Date.now() - input.startedAt }); return { ok: true, followSymlinks: decision.ok ? decision.followSymlinks : false, maxBytes: decision.maxBytes }; } await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.path, decision: approval.decision === "allow-always" ? "allowed:always" : "allowed:once", durationMs: Date.now() - input.startedAt }); return { ok: true, followSymlinks: decision.ok ? decision.followSymlinks : false, maxBytes: decision.maxBytes }; } function prepareParams(input) { const next = { ...input.params, followSymlinks: input.followSymlinks }; delete next.preflightOnly; 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; } function readResultPayload(result) { return result.payload && typeof result.payload === "object" && !Array.isArray(result.payload) ? result.payload : null; } 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 }; } 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"); return await new Promise((resolve) => { const child = spawn(process.platform !== "win32" ? "/usr/bin/tar" : "tar", ["-tzf", "-"], { stdio: [ "pipe", "pipe", "pipe" ] }); const entries = []; let pending = ""; let outputBytes = 0; let stderr = ""; let settled = false; const finish = (result) => { if (settled) return; settled = true; clearTimeout(watchdog); resolve(result); }; const stopChild = () => { try { child.kill("SIGKILL"); } catch {} }; const appendLine = (line) => { if (settled) return false; const entry = normalizeTarEntryPath(line); if (entry !== null) { entries.push(entry); if (entries.length > DIR_FETCH_MAX_ENTRIES) { stopChild(); finish({ ok: false, code: "ARCHIVE_ENTRIES_TOO_MANY", reason: `dir.fetch archive contains more than ${DIR_FETCH_MAX_ENTRIES} entries` }); return false; } } return true; }; const watchdog = setTimeout(() => { stopChild(); finish({ ok: false, code: "ARCHIVE_ENTRIES_UNREADABLE", reason: "tar -tzf timed out" }); }, DIR_FETCH_ARCHIVE_LIST_TIMEOUT_MS); child.stdout.on("data", (chunk) => { if (settled) return; outputBytes += chunk.byteLength; if (outputBytes > DIR_FETCH_ARCHIVE_LIST_MAX_OUTPUT_BYTES) { stopChild(); finish({ ok: false, code: "ARCHIVE_ENTRIES_UNREADABLE", reason: "tar -tzf output too large" }); return; } const lines = `${pending}${chunk.toString()}`.split("\n"); pending = lines.pop() ?? ""; for (const line of lines) if (!appendLine(line)) return; }); child.stderr.on("data", (chunk) => { stderr = appendBoundedTextTail(stderr, chunk, DIR_FETCH_ARCHIVE_LIST_STDERR_TAIL_CHARS); }); child.on("close", (code) => { if (settled) return; if (code !== 0) { finish({ ok: false, code: "ARCHIVE_ENTRIES_UNREADABLE", reason: `tar -tzf exited ${code}: ${stderr.slice(-200)}` }); return; } if (pending) { if (!appendLine(pending)) return; } finish({ ok: true, entries }); }); child.on("error", (error) => { finish({ ok: false, code: "ARCHIVE_ENTRIES_UNREADABLE", reason: `tar -tzf error: ${String(error)}` }); }); child.stdin.on("error", (error) => { if (settled && error.code === "EPIPE") return; finish({ ok: false, code: "ARCHIVE_ENTRIES_UNREADABLE", reason: `tar -tzf input error: ${String(error)}` }); }); child.stdin.end(tarBuffer); }); } 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 > DIR_FETCH_MAX_ENTRIES) { 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 policy = evaluateFilePolicy({ nodeId: input.ctx.nodeId, nodeDisplayName, kind: "read", path: candidate, pluginConfig: input.ctx.pluginConfig }); 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 } }); 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} failed: ${preflight.message}`, details: preflight.details, unavailable: true } }; } const payload = readResultPayload(preflight); if (payload?.ok === false) { await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.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() - input.startedAt }); return { ok: false, result: preflight }; } return { ok: true, payload, canonicalPath: payload && typeof payload.path === "string" && payload.path ? payload.path : input.requestedPath }; } async function runPathPreflight(input) { const preflight = await invokePreflight(input); if (!preflight.ok) return preflight.result; const nodeDisplayName = input.ctx.node?.displayName; const { canonicalPath } = preflight; if (canonicalPath === input.requestedPath) return null; const policy = evaluateFilePolicy({ nodeId: input.ctx.nodeId, nodeDisplayName, kind: input.kind, path: canonicalPath, pluginConfig: input.ctx.pluginConfig }); if (policy.ok) return null; await appendFileTransferAudit({ op: input.op, nodeId: input.ctx.nodeId, nodeDisplayName, requestedPath: input.requestedPath, canonicalPath, decision: "denied:symlink_escape", errorCode: policy.code, reason: policy.reason, durationMs: Date.now() - input.startedAt }); return { ok: false, code: "SYMLINK_TARGET_DENIED", message: `${input.op} SYMLINK_TARGET_DENIED: requested path resolved to ${canonicalPath} which is not allowed by policy` }; } async function runDirFetchPreflight(input) { const preflight = await invokePreflight(input); if (!preflight.ok) return preflight.result; return await validateDirFetchEntries({ ctx: input.ctx, op: input.op, requestedPath: input.requestedPath, canonicalPath: preflight.canonicalPath, entries: preflight.payload?.entries, startedAt: input.startedAt, phase: "preflight" }); } 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 = asRecord(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) }; } if (command === "file.fetch") { const preflightDeny = await runPathPreflight({ ctx, op, kind: "read", params: forwardedParams, requestedPath, startedAt }); if (preflightDeny) return preflightDeny; } else if (command === "file.write") { const preflightDeny = await runPathPreflight({ ctx, op, kind: "write", params: forwardedParams, requestedPath, startedAt }); if (preflightDeny) return preflightDeny; } else if (command === "dir.fetch") { const preflightDeny = await runDirFetchPreflight({ ctx, op, params: forwardedParams, requestedPath, startedAt }); if (preflightDeny) return preflightDeny; } 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 ? payload.path : requestedPath; if (canonicalPath !== requestedPath) { const postflight = evaluateFilePolicy({ nodeId: ctx.nodeId, nodeDisplayName, kind: commandKind(command), path: canonicalPath, pluginConfig: ctx.pluginConfig }); if (!postflight.ok) { await appendFileTransferAudit({ op, nodeId: ctx.nodeId, nodeDisplayName, requestedPath, canonicalPath, decision: "denied:symlink_escape", errorCode: postflight.code, reason: postflight.reason, durationMs: Date.now() - startedAt }); return { ok: false, code: "SYMLINK_TARGET_DENIED", message: `${op} SYMLINK_TARGET_DENIED: requested path resolved to ${canonicalPath} which is not allowed by policy` }; } } 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, requestedPath, canonicalPath, entries: archiveEntries.entries, startedAt, phase: "archive" }); if (archiveDeny) return archiveDeny; } await appendFileTransferAudit({ op, nodeId: ctx.nodeId, nodeDisplayName, requestedPath, canonicalPath, decision: "allowed", sizeBytes: typeof payload?.size === "number" ? payload.size : void 0, sha256: typeof payload?.sha256 === "string" ? payload.sha256 : void 0, durationMs: Date.now() - startedAt }); return result; } function createFileTransferNodeInvokePolicy() { return { commands: [...FILE_TRANSFER_NODE_INVOKE_COMMANDS], handle: handleFileTransferInvoke }; } //#endregion export { createFileTransferNodeInvokePolicy };