openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
282 lines (281 loc) • 11.5 kB
JavaScript
import { i as asOptionalObjectRecord, r as asNullableRecord } from "./record-coerce-DItp3I4t.js";
import { n as getRuntimeConfig } from "./io.runtime-B9iJRs3w.js";
import { t as mutateConfigFile } from "./mutate-ZNN4iFCn.js";
import "./string-coerce-runtime-GQa0ehRA.js";
import "./config-mutation-CUEqNwmX.js";
import "./runtime-config-snapshot-u1Kj68kx.js";
import { t as FILE_TRANSFER_NODE_INVOKE_COMMANDS } from "./node-invoke-policy-commands-DfRVjRLi.js";
import path from "node:path";
import os from "node:os";
import { minimatch } from "minimatch";
function asFilePolicyConfig(value) {
return asNullableRecord(value);
}
function readFileTransferConfigFromPluginConfig(pluginConfig) {
const pluginRecord = asNullableRecord(pluginConfig);
if (!pluginRecord) return null;
return {
policyVersion: typeof pluginRecord.policyVersion === "number" ? pluginRecord.policyVersion : void 0,
nodes: asFilePolicyConfig(pluginRecord.nodes) ?? void 0,
literalGrants: pluginRecord.literalGrants,
pendingReapprovals: pluginRecord.pendingReapprovals
};
}
function readPendingReapprovals(config) {
if (config.policyVersion !== 2 || !Array.isArray(config.pendingReapprovals)) return [];
return config.pendingReapprovals.flatMap((value) => {
const pending = asNullableRecord(value);
if (!pending || typeof pending.selector !== "string" || pending.kind !== "read" && pending.kind !== "write" || typeof pending.path !== "string") return [];
return [{
selector: pending.selector,
kind: pending.kind,
path: pending.path
}];
});
}
function matchesPendingReapproval(input, policySelector, pending) {
return pending.kind === input.kind && pending.path === input.path && pending.selector === policySelector;
}
function readPluginConfigFromRuntimeConfig() {
const cfg = getRuntimeConfig();
const plugins = asOptionalObjectRecord(cfg.plugins);
if (!plugins) return null;
const entries = asOptionalObjectRecord(plugins.entries);
if (!entries) return null;
const entry = asOptionalObjectRecord(entries["file-transfer"]);
if (!entry) return null;
return asNullableRecord(entry.config);
}
function readFileTransferConfig(pluginConfig) {
return readFileTransferConfigFromPluginConfig(readPluginConfigFromRuntimeConfig()) ?? readFileTransferConfigFromPluginConfig(pluginConfig);
}
function readNodes(config) {
return asFilePolicyConfig(config.nodes);
}
function hasLegacyPositiveRules(config) {
const nodes = readNodes(config);
if (!nodes) return false;
return Object.values(nodes).some((entry) => Array.isArray(entry.allowReadPaths) && entry.allowReadPaths.length > 0 || Array.isArray(entry.allowWritePaths) && entry.allowWritePaths.length > 0);
}
function readLiteralGrants(config) {
if (config.policyVersion !== 2 || !Array.isArray(config.literalGrants)) return [];
return config.literalGrants.flatMap((value) => {
const grant = asNullableRecord(value);
if (!grant || typeof grant.nodeId !== "string" || !isFileTransferCommand(grant.command) || typeof grant.requestedPath !== "string" || typeof grant.canonicalPath !== "string") return [];
return [{
nodeId: grant.nodeId,
command: grant.command,
requestedPath: grant.requestedPath,
canonicalPath: grant.canonicalPath
}];
});
}
function isFileTransferCommand(value) {
return typeof value === "string" && FILE_TRANSFER_NODE_INVOKE_COMMANDS.some((command) => command === value);
}
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 matchesAnyDeny(target, patterns) {
if (matchesAny(target, patterns)) return true;
return matchesAny(`${target.replace(/[\\/]+$/u, "")}/`, patterns);
}
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 evaluateFilePolicyInternal(input, constraintsOnly) {
if (containsParentRefSegment(input.path)) return {
ok: false,
code: "POLICY_DENIED",
reason: "path contains '..' segments; reject before glob match",
askable: false
};
const pluginPolicy = readFileTransferConfig(input.pluginConfig);
const config = pluginPolicy ? readNodes(pluginPolicy) : null;
if (!pluginPolicy || !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
};
if (pluginPolicy.policyVersion !== 2 && hasLegacyPositiveRules(pluginPolicy)) return {
ok: false,
code: "POLICY_MIGRATION_REQUIRED",
reason: "older file-transfer permissions need review; run `openclaw file-transfer approvals migrate`",
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 (matchesAnyDeny(input.path, denyPatterns)) return {
ok: false,
code: "POLICY_DENIED",
reason: "path matches a denyPaths pattern",
askable: false,
askMode,
maxBytes,
followSymlinks
};
if (constraintsOnly) return {
ok: true,
reason: "matched-allow",
maxBytes,
followSymlinks
};
const pendingReapproval = readPendingReapprovals(pluginPolicy).find((pending) => matchesPendingReapproval(input, resolved.key, pending));
if (askMode === "always") return {
ok: true,
reason: "ask-always",
askMode,
maxBytes,
followSymlinks,
pendingReapprovalSelector: pendingReapproval?.selector
};
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 (input.command) {
const literal = readLiteralGrants(pluginPolicy).find((grant) => grant.nodeId === input.nodeId && grant.command === input.command && grant.requestedPath === input.path);
if (literal) return {
ok: true,
reason: "matched-literal",
expectedCanonicalPath: literal.canonicalPath,
maxBytes,
followSymlinks
};
}
if (pendingReapproval) return {
ok: false,
code: "POLICY_DENIED",
reason: "path requires exact reapproval",
askable: true,
askMode,
maxBytes,
followSymlinks,
pendingReapprovalSelector: pendingReapproval.selector
};
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
};
}
function evaluateFilePolicy(input) {
return evaluateFilePolicyInternal(input, false);
}
function evaluateFilePolicyConstraints(input) {
return evaluateFilePolicyInternal(input, true);
}
/** Persist an exact standing grant only after node canonical-path validation. */
async function persistLiteralGrant(input) {
if (!isFileTransferCommand(input.command)) throw new Error("unsupported file-transfer command");
if (!input.nodeId || !input.requestedPath || !input.canonicalPath) throw new Error("file-transfer literal grant requires node, requested, and canonical paths");
await mutateConfigFile({
afterWrite: {
mode: "none",
reason: "file-transfer literal approval update"
},
mutate: (draft) => {
const plugins = draft.plugins ??= {};
const entries = plugins.entries ??= {};
const pluginEntry = entries["file-transfer"] ??= {};
const policyConfig = pluginEntry.config ??= {};
if (policyConfig.policyVersion !== 2 && hasLegacyPositiveRules(policyConfig)) throw new Error("older file-transfer permissions need review; run `openclaw file-transfer approvals migrate`");
policyConfig.policyVersion = 2;
const grants = readLiteralGrants(policyConfig).filter((grant) => grant.nodeId !== input.nodeId || grant.command !== input.command || grant.requestedPath !== input.requestedPath);
grants.push({
nodeId: input.nodeId,
command: input.command,
requestedPath: input.requestedPath,
canonicalPath: input.canonicalPath
});
policyConfig.literalGrants = grants;
const kind = input.command === "file.write" ? "write" : "read";
policyConfig.pendingReapprovals = readPendingReapprovals(policyConfig).filter((pending) => pending.kind !== kind || pending.path !== input.requestedPath || pending.selector !== input.pendingReapprovalSelector);
}
});
}
//#endregion
export { evaluateFilePolicyConstraints as n, persistLiteralGrant as r, evaluateFilePolicy as t };