Anforderungen  |   Konzepte  |   Entwurf  |   Entwicklung  |   Qualitätssicherung  |   Lebenszyklus  |   Steuerung
 
 
 
 


Quelle  fs-bridge.ts

  Sprache: JAVA
 

Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]

import fs from "node:fs";
import fsPromises from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import path from "node:path";
import { writeFileWithinRoot } from "openclaw/plugin-sdk/infra-runtime";
import type {
  SandboxFsBridge,
  SandboxFsStat,
  SandboxResolvedPath,
} from "openclaw/plugin-sdk/sandbox";
import { createWritableRenameTargetResolver } from "openclaw/plugin-sdk/sandbox";
import type { OpenShellFsBridgeContext, OpenShellSandboxBackend } from "./backend.types.js";
import { movePathWithCopyFallback } from "./mirror.js";

type ResolvedMountPath = SandboxResolvedPath & {
  mountHostRoot: string;
  writable: boolean;
  source: "workspace" | "agent";
};

export function createOpenShellFsBridge(params: {
  sandbox: OpenShellFsBridgeContext;
  backend: OpenShellSandboxBackend;
}): SandboxFsBridge {
  return new OpenShellFsBridge(params.sandbox, params.backend);
}

class OpenShellFsBridge implements SandboxFsBridge {
  private readonly resolveRenameTargets = createWritableRenameTargetResolver(
    (target) => this.resolveTarget(target),
    (target, action) => this.ensureWritable(target, action),
  );

  constructor(
    private readonly sandbox: OpenShellFsBridgeContext,
    private readonly backend: OpenShellSandboxBackend,
  ) {}

  resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath {
    const target = this.resolveTarget(params);
    return {
      hostPath: target.hostPath,
      relativePath: target.relativePath,
      containerPath: target.containerPath,
    };
  }

  async readFile(params: {
    filePath: string;
    cwd?: string;
    signal?: AbortSignal;
  }): Promise<Buffer> {
    const target = this.resolveTarget(params);
    const hostPath = this.requireHostPath(target);
    const handle = await openPinnedReadableFile({
      absolutePath: hostPath,
      rootPath: target.mountHostRoot,
      containerPath: target.containerPath,
    });
    try {
      return (await handle.readFile()) as Buffer;
    } finally {
      await handle.close();
    }
  }

  async writeFile(params: {
    filePath: string;
    cwd?: string;
    data: Buffer | string;
    encoding?: BufferEncoding;
    mkdir?: boolean;
    signal?: AbortSignal;
  }): Promise<void> {
    const target = this.resolveTarget(params);
    const hostPath = this.requireHostPath(target);
    this.ensureWritable(target, "write files");
    await assertLocalPathSafety({
      target,
      root: target.mountHostRoot,
      allowMissingLeaf: true,
      allowFinalSymlinkForUnlink: false,
    });
    const buffer = Buffer.isBuffer(params.data)
      ? params.data
      : Buffer.from(params.data, params.encoding ?? "utf8");
    await writeFileWithinRoot({
      rootDir: target.mountHostRoot,
      relativePath: path.relative(target.mountHostRoot, hostPath),
      data: buffer,
      mkdir: params.mkdir,
    });
    await this.backend.syncLocalPathToRemote(hostPath, target.containerPath);
  }

  async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<void> {
    const target = this.resolveTarget(params);
    const hostPath = this.requireHostPath(target);
    this.ensureWritable(target, "create directories");
    await assertLocalPathSafety({
      target,
      root: target.mountHostRoot,
      allowMissingLeaf: true,
      allowFinalSymlinkForUnlink: false,
    });
    await fsPromises.mkdir(hostPath, { recursive: true });
    await this.backend.runRemoteShellScript({
      script: 'mkdir -p -- "$1"',
      args: [target.containerPath],
      signal: params.signal,
    });
  }

  async remove(params: {
    filePath: string;
    cwd?: string;
    recursive?: boolean;
    force?: boolean;
    signal?: AbortSignal;
  }): Promise<void> {
    const target = this.resolveTarget(params);
    const hostPath = this.requireHostPath(target);
    this.ensureWritable(target, "remove files");
    await assertLocalPathSafety({
      target,
      root: target.mountHostRoot,
      allowMissingLeaf: params.force !== false,
      allowFinalSymlinkForUnlink: true,
    });
    await fsPromises.rm(hostPath, {
      recursive: params.recursive ?? false,
      force: params.force !== false,
    });
    await this.backend.runRemoteShellScript({
      script: params.recursive
        ? 'rm -rf -- "$1"'
        : 'if [ -d "$1" ] && [ ! -L "$1" ]; then rmdir -- "$1"; elif [ -e "$1" ] || [ -L "$1" ]; then rm -f -- "$1"; fi',
      args: [target.containerPath],
      signal: params.signal,
      allowFailure: params.force !== false,
    });
  }

  async rename(params: {
    from: string;
    to: string;
    cwd?: string;
    signal?: AbortSignal;
  }): Promise<void> {
    const { from, to } = this.resolveRenameTargets(params);
    const fromHostPath = this.requireHostPath(from);
    const toHostPath = this.requireHostPath(to);
    await assertLocalPathSafety({
      target: from,
      root: from.mountHostRoot,
      allowMissingLeaf: false,
      allowFinalSymlinkForUnlink: true,
    });
    await assertLocalPathSafety({
      target: to,
      root: to.mountHostRoot,
      allowMissingLeaf: true,
      allowFinalSymlinkForUnlink: false,
    });
    await fsPromises.mkdir(path.dirname(toHostPath), { recursive: true });
    await movePathWithCopyFallback({ from: fromHostPath, to: toHostPath });
    await this.backend.runRemoteShellScript({
      script: 'mkdir -p -- "$(dirname -- "$2")" && mv -- "$1" "$2"',
      args: [from.containerPath, to.containerPath],
      signal: params.signal,
    });
  }

  async stat(params: {
    filePath: string;
    cwd?: string;
    signal?: AbortSignal;
  }): Promise<SandboxFsStat | null> {
    const target = this.resolveTarget(params);
    const hostPath = this.requireHostPath(target);
    const stats = await fsPromises.lstat(hostPath).catch(() => null);
    if (!stats) {
      return null;
    }
    await assertLocalPathSafety({
      target,
      root: target.mountHostRoot,
      allowMissingLeaf: false,
      allowFinalSymlinkForUnlink: false,
    });
    return {
      type: stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other",
      size: stats.size,
      mtimeMs: stats.mtimeMs,
    };
  }

  private ensureWritable(target: ResolvedMountPath, action: string) {
    if (this.sandbox.workspaceAccess !== "rw" || !target.writable) {
      throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`);
    }
  }

  private requireHostPath(target: ResolvedMountPath): string {
    if (!target.hostPath) {
      throw new Error(
        `OpenShell mirror bridge requires a local host path: ${target.containerPath}`,
      );
    }
    return target.hostPath;
  }

  private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedMountPath {
    const workspaceRoot = path.resolve(this.sandbox.workspaceDir);
    const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir);
    const hasAgentMount = this.sandbox.workspaceAccess !== "none" && workspaceRoot !== agentRoot;
    const agentContainerRoot = (this.backend.remoteAgentWorkspaceDir || "/agent").replace(
      /\\/g,
      "/",
    );
    const workspaceContainerRoot = this.sandbox.containerWorkdir.replace(/\\/g, "/");
    const input = params.filePath.trim();

    if (input.startsWith(`${workspaceContainerRoot}/`) || input === workspaceContainerRoot) {
      const relative = path.posix.relative(workspaceContainerRoot, input) || "";
      const hostPath = relative
        ? path.resolve(workspaceRoot, ...relative.split("/"))
        : workspaceRoot;
      return {
        hostPath,
        relativePath: relative,
        containerPath: relative
          ? path.posix.join(workspaceContainerRoot, relative)
          : workspaceContainerRoot,
        mountHostRoot: workspaceRoot,
        writable: this.sandbox.workspaceAccess === "rw",
        source: "workspace",
      };
    }

    if (
      hasAgentMount &&
      (input.startsWith(`${agentContainerRoot}/`) || input === agentContainerRoot)
    ) {
      const relative = path.posix.relative(agentContainerRoot, input) || "";
      const hostPath = relative ? path.resolve(agentRoot, ...relative.split("/")) : agentRoot;
      return {
        hostPath,
        relativePath: relative ? agentContainerRoot + "/" + relative : agentContainerRoot,
        containerPath: relative
          ? path.posix.join(agentContainerRoot, relative)
          : agentContainerRoot,
        mountHostRoot: agentRoot,
        writable: this.sandbox.workspaceAccess === "rw",
        source: "agent",
      };
    }

    const cwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot;
    const hostPath = path.isAbsolute(input) ? path.resolve(input) : path.resolve(cwd, input);

    if (isPathInside(workspaceRoot, hostPath)) {
      const relative = path.relative(workspaceRoot, hostPath).split(path.sep).join(path.posix.sep);
      return {
        hostPath,
        relativePath: relative,
        containerPath: relative
          ? path.posix.join(workspaceContainerRoot, relative)
          : workspaceContainerRoot,
        mountHostRoot: workspaceRoot,
        writable: this.sandbox.workspaceAccess === "rw",
        source: "workspace",
      };
    }

    if (hasAgentMount && isPathInside(agentRoot, hostPath)) {
      const relative = path.relative(agentRoot, hostPath).split(path.sep).join(path.posix.sep);
      return {
        hostPath,
        relativePath: relative ? `${agentContainerRoot}/${relative}` : agentContainerRoot,
        containerPath: relative
          ? path.posix.join(agentContainerRoot, relative)
          : agentContainerRoot,
        mountHostRoot: agentRoot,
        writable: this.sandbox.workspaceAccess === "rw",
        source: "agent",
      };
    }

    throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`);
  }
}

function isPathInside(root: string, target: string): boolean {
  const relative = path.relative(root, target);
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}

async function assertLocalPathSafety(params: {
  target: ResolvedMountPath;
  root: string;
  allowMissingLeaf: boolean;
  allowFinalSymlinkForUnlink: boolean;
}): Promise<void> {
  if (!params.target.hostPath) {
    throw new Error(`Missing local host path for ${params.target.containerPath}`);
  }
  const canonicalRoot = await fsPromises
    .realpath(params.root)
    .catch(() => path.resolve(params.root));
  const candidate = await resolveCanonicalCandidate(params.target.hostPath);
  if (!isPathInside(canonicalRoot, candidate)) {
    throw new Error(
      `Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`,
    );
  }

  const relative = path.relative(params.root, params.target.hostPath);
  const segments = relative
    .split(path.sep)
    .filter(Boolean)
    .slice(0, Math.max(0, relative.split(path.sep).filter(Boolean).length));
  let cursor = params.root;
  for (let index = 0; index < segments.length; index += 1) {
    cursor = path.join(cursor, segments[index]);
    const stats = await fsPromises.lstat(cursor).catch(() => null);
    if (!stats) {
      if (index === segments.length - 1 && params.allowMissingLeaf) {
        return;
      }
      continue;
    }
    const isFinal = index === segments.length - 1;
    if (stats.isSymbolicLink() && (!isFinal || !params.allowFinalSymlinkForUnlink)) {
      throw new Error(`Sandbox boundary checks failed: ${params.target.containerPath}`);
    }
  }
}

async function resolveCanonicalCandidate(targetPath: string): Promise<string> {
  const missing: string[] = [];
  let cursor = path.resolve(targetPath);
  while (true) {
    const exists = await fsPromises
      .lstat(cursor)
      .then(() => true)
      .catch(() => false);
    if (exists) {
      const canonical = await fsPromises.realpath(cursor).catch(() => cursor);
      return path.resolve(canonical, ...missing);
    }
    const parent = path.dirname(cursor);
    if (parent === cursor) {
      return path.resolve(cursor, ...missing);
    }
    missing.unshift(path.basename(cursor));
    cursor = parent;
  }
}

async function openPinnedReadableFile(params: {
  absolutePath: string;
  rootPath: string;
  containerPath: string;
}): Promise<FileHandle> {
  // The literal root is what `resolveTarget` joins caller-provided relative
  // paths against, so pre-open containment must be checked in literal form.
  // The canonical root is derived separately and used for the post-open
  // path checks (fd-path readlink and realpath cross-check), so a workspace
  // that is itself configured as a symlink still works.
  const literalRoot = path.resolve(params.rootPath);
  const canonicalRoot = await fsPromises.realpath(literalRoot).catch(() => literalRoot);
  const literalPath = path.resolve(params.absolutePath);
  // Cheap string-prefix check on the caller-provided absolute path; no
  // filesystem state is read here, so there is no TOCTOU window. Deeper
  // checks run after the fd is pinned.
  if (!isPathInside(literalRoot, literalPath)) {
    throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.containerPath}`);
  }
  const { flags: openReadFlags, supportsNoFollow } = resolveOpenReadFlags();
  // Open first so every later check runs against an fd that is already pinned
  // to one specific inode. `O_NOFOLLOW` prevents the final path component from
  // being a symlink; the ancestor walk below handles parent-directory symlink
  // swaps on platforms where fd-path readlink is not available.
  const handle = await fsPromises.open(literalPath, openReadFlags);
  try {
    const openedStat = await handle.stat();
    if (!openedStat.isFile()) {
      throw new Error(`Sandbox boundary checks failed; cannot read files: ${params.containerPath}`);
    }
    if (openedStat.nlink > 1) {
      throw new Error(`Sandbox boundary checks failed; cannot read files: ${params.containerPath}`);
    }
    const resolvedPath = await resolveOpenedReadablePath(handle.fd);
    if (resolvedPath !== null) {
      // Primary guarantee on Linux: the fd's resolved path is derived from the
      // kernel, so a parent-directory swap cannot make this return a stale path.
      if (!isPathInside(canonicalRoot, resolvedPath)) {
        throw new Error(
          `Sandbox boundary checks failed; cannot read files: ${params.containerPath}`,
        );
      }
      return handle;
    }
    // Fallback for platforms where fd-path readlink is unavailable. On macOS,
    // `/dev/fd/N` is a character device so readlink returns EINVAL; on Windows
    // there is no `/proc` equivalent. With no kernel-backed path readback we
    // must prove the pinned fd is in-root without trusting a separate
    // `realpath` + `lstat` pair that would race between the two awaits. Walk
    // every ancestor between `literalRoot` and `literalPath` — the actual
    // on-disk chain — and reject if any ancestor is a symlink, then use a
    // single `stat` call to confirm that the path still resolves to the
    // same file the fd has pinned. `fs.promises.stat` resolves the path and
    // returns the final file's identity in one syscall, so there is no
    // between-await window for an attacker to race.
    await assertAncestorChainHasNoSymlinks(literalRoot, literalPath, params.containerPath, {
      // On platforms where `O_NOFOLLOW` is unavailable (Windows), the open
      // call would have transparently followed a final-component symlink, so
      // the ancestor walk has to lstat the leaf as well.
      includeLeaf: !supportsNoFollow,
    });
    const currentResolvedStat = await fsPromises.stat(literalPath);
    if (!sameFileIdentity(currentResolvedStat, openedStat)) {
      throw new Error(`Sandbox boundary checks failed; cannot read files: ${params.containerPath}`);
    }
    // Belt-and-suspenders: re-fstat the pinned fd after the identity check and
    // confirm the file type and link count are still trustworthy. A hardlink
    // that appeared between the initial fstat and here is not exploitable for
    // the read (the fd is already pinned to the original inode), but failing
    // closed here keeps the guarantee simple: the bytes we return always come
    // from a file that was a single-linked regular file at verification time.
    const postCheckStat = await handle.stat();
    if (!postCheckStat.isFile() || postCheckStat.nlink > 1) {
      throw new Error(`Sandbox boundary checks failed; cannot read files: ${params.containerPath}`);
    }
    return handle;
  } catch (error) {
    await handle.close();
    throw error;
  }
}

// Walks each directory between canonicalRoot (exclusive) and
// targetAbsolutePath, `lstat`'ing each segment. Rejects if any intermediate
// segment is a symlink or a non-directory. By default the final component is
// not walked because `O_NOFOLLOW` already protects it on the open call. Pass
// `includeLeaf: true` on platforms where `O_NOFOLLOW` is unavailable
// (Windows) so a symlinked leaf cannot be followed silently by `open`.
async function assertAncestorChainHasNoSymlinks(
  canonicalRoot: string,
  targetAbsolutePath: string,
  containerPath: string,
  options: { includeLeaf?: boolean } = {},
): Promise<void> {
  const relative = path.relative(canonicalRoot, targetAbsolutePath);
  if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
    return;
  }
  const segments = relative.split(path.sep).filter((segment) => segment.length > 0);
  const lastIndex = options.includeLeaf ? segments.length : segments.length - 1;
  let cursor = canonicalRoot;
  for (let i = 0; i < lastIndex; i += 1) {
    cursor = path.join(cursor, segments[i]);
    const stat = await fsPromises.lstat(cursor).catch(() => null);
    if (!stat) {
      throw new Error(`Sandbox boundary checks failed; cannot read files: ${containerPath}`);
    }
    const isLeaf = i === segments.length - 1;
    if (stat.isSymbolicLink()) {
      throw new Error(`Sandbox boundary checks failed; cannot read files: ${containerPath}`);
    }
    if (!isLeaf && !stat.isDirectory()) {
      throw new Error(`Sandbox boundary checks failed; cannot read files: ${containerPath}`);
    }
  }
}

type ReadOpenFlagsResolution = { flags: number; supportsNoFollow: boolean };

let readOpenFlagsResolverForTest: (() => ReadOpenFlagsResolution) | undefined;

function resolveOpenReadFlags(): ReadOpenFlagsResolution {
  if (readOpenFlagsResolverForTest) {
    return readOpenFlagsResolverForTest();
  }
  const closeOnExec = (fs.constants as Record<string, number>).O_CLOEXEC ?? 0;
  const supportsNoFollow = typeof fs.constants.O_NOFOLLOW === "number";
  const noFollow = supportsNoFollow ? fs.constants.O_NOFOLLOW : 0;
  return {
    flags: fs.constants.O_RDONLY | noFollow | closeOnExec,
    supportsNoFollow,
  };
}

/**
 * Test-only seam for forcing the open-flag/`O_NOFOLLOW` resolution. Used to
 * exercise the Windows-style fallback (no `O_NOFOLLOW`, ancestor walk
 * includes the leaf) on platforms where `fs.constants.O_NOFOLLOW` is a
 * non-configurable native data property and cannot be patched directly.
 *
 * @internal
 */
export function setReadOpenFlagsResolverForTest(
  resolver: (() => ReadOpenFlagsResolution) | undefined,
): void {
  readOpenFlagsResolverForTest = resolver;
}

// Resolves the absolute path associated with an open fd via the kernel-backed
// `/proc/self/fd/<fd>` (Linux) or `/dev/fd/<fd>` (some BSDs). Returns null
// when no fd-path endpoint is available. Note: on macOS `/dev/fd/N` is a
// character device rather than a symlink, so `readlink` fails with EINVAL
// there and the caller must use the ancestor-walk fallback instead.
async function resolveOpenedReadablePath(fd: number): Promise<string | null> {
  for (const fdPath of [`/proc/self/fd/${fd}`, `/dev/fd/${fd}`]) {
    try {
      const openedPath = await fsPromises.readlink(fdPath);
      return normalizeOpenedReadablePath(openedPath);
    } catch {
      continue;
    }
  }
  return null;
}

function normalizeOpenedReadablePath(openedPath: string): string {
  const deletedSuffix = " (deleted)";
  const withoutDeletedSuffix = openedPath.endsWith(deletedSuffix)
    ? openedPath.slice(0, -deletedSuffix.length)
    : openedPath;
  return path.resolve(withoutDeletedSuffix);
}

// File identity comparison with win32-aware `dev=0` handling, matching the
// shared `src/infra/file-identity.ts` contract. Kept local because extension
// production code is not allowed to reach into core `src/**` by relative
// import, and this helper is not yet part of the `openclaw/plugin-sdk/*`
// public surface. Stats here come from `FileHandle.stat()` / `fs.promises.stat()`
// with no `{ bigint: true }` option, so all fields are numbers.
function sameFileIdentity(
  left: { dev: number; ino: number },
  right: { dev: number; ino: number },
  platform: NodeJS.Platform = process.platform,
): boolean {
  if (left.ino !== right.ino) {
    return false;
  }
  if (left.dev === right.dev) {
    return true;
  }
  // On Windows, path-based stat can report `dev=0` while fd-based stat reports
  // a real volume serial. Treat either side `dev=0` as "unknown device"
  // rather than a mismatch so legitimate Windows fallback reads are not
  // rejected.
  return platform === "win32" && (left.dev === 0 || right.dev === 0);
}

¤ Dauer der Verarbeitung: 0.25 Sekunden  (vorverarbeitet am  2026-04-27) ¤

*© Formatika GbR, Deutschland






Wurzel

Suchen

Beweissystem der NASA

Beweissystem Isabelle

NIST Cobol Testsuite

Cephes Mathematical Library

Wiener Entwicklungsmethode

Haftungshinweis

Die Informationen auf dieser Webseite wurden nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit, noch Qualität der bereit gestellten Informationen zugesichert.

Bemerkung:

Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.






                                                                                                                                                                                                                                                                                                                                                                                                     


Neuigkeiten

     Aktuelles
     Motto des Tages

Software

     Produkte
     Quellcodebibliothek

Aktivitäten

     Artikel über Sicherheit
     Anleitung zur Aktivierung von SSL

Muße

     Gedichte
     Musik
     Bilder

Jenseits des Üblichen ....

Besucherstatistik

Besucherstatistik

Monitoring

Montastic status badge