[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
367
electron/plugins/filesystemBroker.cjs
Normal file
367
electron/plugins/filesystemBroker.cjs
Normal file
@@ -0,0 +1,367 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
|
||||
const { PLUGIN_RPC_MAX_RAW_BYTES } = require("./constants.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
const MAX_FILESYSTEM_BYTES = PLUGIN_RPC_MAX_RAW_BYTES;
|
||||
const MAX_DIRECTORY_ENTRIES = 1_000;
|
||||
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
||||
|
||||
function invalidArgument(message) {
|
||||
return new PluginRpcError(RPC_ERRORS.invalidArgument, message);
|
||||
}
|
||||
|
||||
function assertAbsolutePath(value) {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| value.length < 1
|
||||
|| value.length > 8_192
|
||||
|| value.includes("\0")
|
||||
|| !path.isAbsolute(value)
|
||||
) {
|
||||
throw invalidArgument("Plugin filesystem path must be absolute");
|
||||
}
|
||||
// Resolve absolute paths lexically so permission resources have one stable
|
||||
// spelling. In particular, realpath() drops trailing separators, and the
|
||||
// post-permission equality checks must compare against the same spelling.
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
function assertReadParams(params) {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
throw invalidArgument("Plugin filesystem read parameters are invalid");
|
||||
}
|
||||
const encoding = params.encoding ?? "utf8";
|
||||
if (encoding !== "utf8" && encoding !== "base64") {
|
||||
throw invalidArgument("Plugin filesystem read encoding is invalid");
|
||||
}
|
||||
const maxBytes = params.maxBytes ?? MAX_FILESYSTEM_BYTES;
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_FILESYSTEM_BYTES) {
|
||||
throw invalidArgument("Plugin filesystem read limit is invalid");
|
||||
}
|
||||
return { path: assertAbsolutePath(params.path), encoding, maxBytes };
|
||||
}
|
||||
|
||||
function decodeWriteData(params) {
|
||||
const encoding = params.encoding ?? "utf8";
|
||||
if (encoding !== "utf8" && encoding !== "base64") {
|
||||
throw invalidArgument("Plugin filesystem write encoding is invalid");
|
||||
}
|
||||
if (typeof params.data !== "string") throw invalidArgument("Plugin filesystem write data is invalid");
|
||||
const bytes = Buffer.from(params.data, encoding);
|
||||
if (encoding === "base64" && bytes.toString("base64") !== params.data) {
|
||||
throw invalidArgument("Plugin filesystem base64 data is not canonical");
|
||||
}
|
||||
if (bytes.byteLength > MAX_FILESYSTEM_BYTES) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin filesystem write is too large");
|
||||
}
|
||||
return { encoding, bytes };
|
||||
}
|
||||
|
||||
function assertWriteParams(params) {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
throw invalidArgument("Plugin filesystem write parameters are invalid");
|
||||
}
|
||||
const { encoding, bytes } = decodeWriteData(params);
|
||||
if (params.overwrite !== undefined && typeof params.overwrite !== "boolean") {
|
||||
throw invalidArgument("Plugin filesystem overwrite flag is invalid");
|
||||
}
|
||||
return {
|
||||
path: assertAbsolutePath(params.path),
|
||||
encoding,
|
||||
bytes,
|
||||
overwrite: params.overwrite === true,
|
||||
};
|
||||
}
|
||||
|
||||
function assertSecureWriteMode(value) {
|
||||
if (!value.overwrite) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.failedPrecondition,
|
||||
"Plugin filesystem writes require explicit overwrite of an existing file",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertPathParams(params) {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
throw invalidArgument("Plugin filesystem parameters are invalid");
|
||||
}
|
||||
return { path: assertAbsolutePath(params.path) };
|
||||
}
|
||||
|
||||
async function resolveExistingPath(requestedPath, fileSystem = fsp) {
|
||||
const canonical = await fileSystem.realpath(requestedPath);
|
||||
const stats = await fileSystem.lstat(canonical);
|
||||
if (stats.isSymbolicLink()) throw invalidArgument("Plugin filesystem path cannot resolve to a symbolic link");
|
||||
return { canonical, stats };
|
||||
}
|
||||
|
||||
async function resolveWritePath(requestedPath, fileSystem = fsp) {
|
||||
try {
|
||||
const existing = await resolveExistingPath(requestedPath, fileSystem);
|
||||
if (!existing.stats.isFile()) throw invalidArgument("Plugin filesystem write target is not a file");
|
||||
return existing;
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.failedPrecondition,
|
||||
"Plugin filesystem writes require an existing regular file",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function entryKind(entry) {
|
||||
if (entry.isFile()) return "file";
|
||||
if (entry.isDirectory()) return "directory";
|
||||
return "other";
|
||||
}
|
||||
|
||||
async function readBoundedFileHandle(handle, maxBytes) {
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
while (total <= maxBytes) {
|
||||
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, (maxBytes + 1) - total));
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, null);
|
||||
if (bytesRead === 0) break;
|
||||
chunks.push(buffer.subarray(0, bytesRead));
|
||||
total += bytesRead;
|
||||
}
|
||||
if (total > maxBytes) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin filesystem file changed or is too large");
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
|
||||
function assertAuthorizedPath(context, permission, canonical) {
|
||||
const authorization = context?.authorization;
|
||||
if (
|
||||
authorization?.permission !== permission
|
||||
|| !Array.isArray(authorization.resources)
|
||||
|| authorization.resources.length !== 1
|
||||
|| authorization.resources[0] !== canonical
|
||||
) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Plugin filesystem path changed after authorization",
|
||||
);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function assertSameOpenedFile(openedStats, pathStats) {
|
||||
if (
|
||||
!openedStats.isFile()
|
||||
|| !pathStats.isFile()
|
||||
|| openedStats.dev !== pathStats.dev
|
||||
|| openedStats.ino !== pathStats.ino
|
||||
) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Plugin filesystem target changed after it was opened",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSameOpenedDirectory(expectedStats, pathStats) {
|
||||
if (
|
||||
!expectedStats.isDirectory()
|
||||
|| !pathStats.isDirectory()
|
||||
|| expectedStats.dev !== pathStats.dev
|
||||
|| expectedStats.ino !== pathStats.ino
|
||||
) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Plugin filesystem directory changed after authorization",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PluginFilesystemBroker {
|
||||
constructor(options = {}) {
|
||||
this.quotaManager = options.quotaManager ?? null;
|
||||
this.fileSystem = options.fileSystem ?? fsp;
|
||||
this.openDirectoryHandle = options.openDirectoryHandle ?? null;
|
||||
if (this.openDirectoryHandle != null && typeof this.openDirectoryHandle !== "function") {
|
||||
throw new TypeError("Secure plugin directory handle adapter must be a function");
|
||||
}
|
||||
}
|
||||
|
||||
validateRead(params) { return assertReadParams(params); }
|
||||
validateWrite(params) {
|
||||
const value = assertWriteParams(params);
|
||||
return {
|
||||
path: value.path,
|
||||
encoding: value.encoding,
|
||||
data: value.bytes.toString(value.encoding),
|
||||
overwrite: value.overwrite,
|
||||
};
|
||||
}
|
||||
validatePath(params) { return assertPathParams(params); }
|
||||
|
||||
describeReadAuthorization(params, resourceKind = "exact") {
|
||||
const value = assertPathParams(params);
|
||||
if (resourceKind !== "exact" && resourceKind !== "directory") {
|
||||
throw invalidArgument("Plugin filesystem authorization kind is invalid");
|
||||
}
|
||||
return {
|
||||
permission: "filesystem.read",
|
||||
resources: [value.path],
|
||||
resourceKinds: [resourceKind],
|
||||
reason: `Read ${value.path}`,
|
||||
operationId: `filesystem.read:${value.path}`,
|
||||
};
|
||||
}
|
||||
|
||||
describeWriteAuthorization(params) {
|
||||
const value = assertSecureWriteMode(assertWriteParams(params));
|
||||
return {
|
||||
permission: "filesystem.write",
|
||||
resources: [value.path],
|
||||
resourceKinds: ["exact"],
|
||||
reason: `Write ${value.path}`,
|
||||
operationId: `filesystem.write:${value.path}`,
|
||||
};
|
||||
}
|
||||
|
||||
async readFile(params, context) {
|
||||
const value = assertReadParams(params);
|
||||
assertAuthorizedPath(context, "filesystem.read", value.path);
|
||||
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
|
||||
assertAuthorizedPath(context, "filesystem.read", canonical);
|
||||
if (!stats.isFile()) throw invalidArgument("Plugin filesystem read target is not a file");
|
||||
if (stats.size > value.maxBytes) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin filesystem file is too large");
|
||||
}
|
||||
const handle = await this.fileSystem.open(canonical, fs.constants.O_RDONLY | NOFOLLOW);
|
||||
try {
|
||||
const [openedStats, pathStats] = await Promise.all([
|
||||
handle.stat(),
|
||||
this.fileSystem.stat(canonical),
|
||||
]);
|
||||
assertSameOpenedFile(openedStats, stats);
|
||||
assertSameOpenedFile(openedStats, pathStats);
|
||||
if (openedStats.size > value.maxBytes) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin filesystem file changed or is too large");
|
||||
}
|
||||
assertAuthorizedPath(context, "filesystem.read", await this.fileSystem.realpath(canonical));
|
||||
const bytes = await readBoundedFileHandle(handle, value.maxBytes);
|
||||
this.quotaManager?.chargeBytes(context.runtimeId, "filesystem", bytes.byteLength);
|
||||
await context.assertActive();
|
||||
return { data: bytes.toString(value.encoding) };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async writeFile(params, context) {
|
||||
const value = assertSecureWriteMode(assertWriteParams(params));
|
||||
assertAuthorizedPath(context, "filesystem.write", value.path);
|
||||
const { canonical, stats } = await resolveWritePath(value.path, this.fileSystem);
|
||||
assertAuthorizedPath(context, "filesystem.write", canonical);
|
||||
this.quotaManager?.chargeBytes(context.runtimeId, "filesystem", value.bytes.byteLength);
|
||||
await context.assertActive();
|
||||
const flags = fs.constants.O_WRONLY | NOFOLLOW;
|
||||
const handle = await this.fileSystem.open(canonical, flags, 0o600);
|
||||
try {
|
||||
assertAuthorizedPath(context, "filesystem.write", await this.fileSystem.realpath(canonical));
|
||||
const [openedStats, pathStats] = await Promise.all([
|
||||
handle.stat(),
|
||||
this.fileSystem.stat(canonical),
|
||||
]);
|
||||
assertSameOpenedFile(openedStats, stats);
|
||||
assertSameOpenedFile(openedStats, pathStats);
|
||||
await context.assertActive();
|
||||
if (value.overwrite) await handle.truncate(0);
|
||||
await handle.writeFile(value.bytes);
|
||||
await handle.sync();
|
||||
await context.assertActive();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async stat(params, context) {
|
||||
const value = assertPathParams(params);
|
||||
assertAuthorizedPath(context, "filesystem.read", value.path);
|
||||
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
|
||||
assertAuthorizedPath(context, "filesystem.read", canonical);
|
||||
await context.assertActive();
|
||||
return {
|
||||
kind: stats.isFile() ? "file" : stats.isDirectory() ? "directory" : "other",
|
||||
size: Math.min(Number.MAX_SAFE_INTEGER, Number(stats.size)),
|
||||
modifiedAt: Math.max(0, Math.trunc(stats.mtimeMs)),
|
||||
};
|
||||
}
|
||||
|
||||
async readDirectory(params, context) {
|
||||
const value = assertPathParams(params);
|
||||
assertAuthorizedPath(context, "filesystem.read", value.path);
|
||||
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
|
||||
assertAuthorizedPath(context, "filesystem.read", canonical);
|
||||
if (!stats.isDirectory()) throw invalidArgument("Plugin filesystem directory target is not a directory");
|
||||
if (!this.openDirectoryHandle) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unsupported,
|
||||
"Secure plugin directory enumeration is unavailable on this runtime",
|
||||
);
|
||||
}
|
||||
const directory = await this.openDirectoryHandle(canonical, { signal: context.signal });
|
||||
if (
|
||||
!directory
|
||||
|| typeof directory.stat !== "function"
|
||||
|| typeof directory.read !== "function"
|
||||
|| typeof directory.close !== "function"
|
||||
) throw new TypeError("Secure plugin directory handle adapter returned an invalid handle");
|
||||
try {
|
||||
assertSameOpenedDirectory(stats, await directory.stat());
|
||||
const entries = [];
|
||||
for (;;) {
|
||||
const entry = await directory.read();
|
||||
if (entry === null) break;
|
||||
entries.push(entry);
|
||||
if (entries.length > MAX_DIRECTORY_ENTRIES) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.resourceExhausted,
|
||||
"Plugin filesystem directory has too many entries",
|
||||
);
|
||||
}
|
||||
}
|
||||
assertSameOpenedDirectory(stats, await directory.stat());
|
||||
assertSameOpenedDirectory(stats, await this.fileSystem.stat(canonical));
|
||||
await context.assertActive();
|
||||
return {
|
||||
entries: entries
|
||||
.map((entry) => ({ name: entry.name, kind: entryKind(entry) }))
|
||||
.sort((left, right) => left.name.localeCompare(right.name, "en")),
|
||||
};
|
||||
} finally {
|
||||
await directory.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_DIRECTORY_ENTRIES,
|
||||
MAX_FILESYSTEM_BYTES,
|
||||
PluginFilesystemBroker,
|
||||
assertAbsolutePath,
|
||||
assertAuthorizedPath,
|
||||
assertSameOpenedFile,
|
||||
assertSameOpenedDirectory,
|
||||
assertPathParams,
|
||||
assertReadParams,
|
||||
assertSecureWriteMode,
|
||||
assertWriteParams,
|
||||
resolveExistingPath,
|
||||
resolveWritePath,
|
||||
readBoundedFileHandle,
|
||||
};
|
||||
Reference in New Issue
Block a user