diff --git a/src/agents/bash-tools.exec-workdir.ts b/src/agents/bash-tools.exec-workdir.ts index 7fa924b5cb41..2d10671b36b9 100644 --- a/src/agents/bash-tools.exec-workdir.ts +++ b/src/agents/bash-tools.exec-workdir.ts @@ -7,7 +7,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { ExecHost } from "../infra/exec-approvals.js"; -import { safeStatSync } from "../infra/path-guards.js"; +import { isPathInside, safeStatSync } from "../infra/path-guards.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; import { assertSandboxPath } from "./sandbox-paths.js"; @@ -63,13 +63,6 @@ function resolveExistingHostWorkdir(workdir: string): string | null { return stats?.isDirectory() ? workdir : null; } -function isHostPathInsideRoot(params: { root: string; candidate: string }): boolean { - const root = path.resolve(params.root); - const candidate = path.resolve(params.candidate); - const relative = path.relative(root, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - function safeCurrentCwd(): string | null { try { return process.cwd(); @@ -275,12 +268,7 @@ function resolveBackendHostWorkdirCandidate(params: { if (readOnlySkillMapping) { return { ...readOnlySkillMapping, failIfInvalid: false }; } - if ( - isHostPathInsideRoot({ - root: params.sandbox.workspaceDir, - candidate: hostPath, - }) - ) { + if (isPathInside(path.resolve(params.sandbox.workspaceDir), hostPath)) { return { hostPath, hostRoot: path.resolve(params.sandbox.workspaceDir), diff --git a/src/agents/sandbox/fs-paths.ts b/src/agents/sandbox/fs-paths.ts index 0bd5f34ecc3c..0c1f981ff77e 100644 --- a/src/agents/sandbox/fs-paths.ts +++ b/src/agents/sandbox/fs-paths.ts @@ -142,7 +142,7 @@ export function resolveWritableSandboxBindHostRoots( if ( !parsed.writable || seen.has(parsed.hostRoot) || - readonlyRoots.some((root) => isHostPathWithinOrEqual(parsed.hostRoot, root)) + readonlyRoots.some((root) => isPathInside(parsed.hostRoot, root)) ) { continue; } @@ -166,7 +166,7 @@ export function hasSandboxBindReadonlyHostShadows(binds: readonly string[] | und const writableRoots = parsedBinds.filter((bind) => bind.writable).map((bind) => bind.hostRoot); const readonlyRoots = parsedBinds.filter((bind) => !bind.writable).map((bind) => bind.hostRoot); return writableRoots.some((writableRoot) => - readonlyRoots.some((readonlyRoot) => isHostPathWithinOrEqual(writableRoot, readonlyRoot)), + readonlyRoots.some((readonlyRoot) => isPathInside(writableRoot, readonlyRoot)), ); } @@ -397,11 +397,6 @@ function isPathInsideHost(root: string, target: string): boolean { return isPathInside(canonicalRoot, canonicalTarget); } -function isHostPathWithinOrEqual(root: string, target: string): boolean { - const relative = path.relative(path.resolve(root), path.resolve(target)); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - function toHostSegments(relativePosix: string): string[] { return relativePosix.split("/").filter(Boolean); } diff --git a/src/agents/sessions/package-manager.ts b/src/agents/sessions/package-manager.ts index 1d907101718f..73636a78c89e 100644 --- a/src/agents/sessions/package-manager.ts +++ b/src/agents/sessions/package-manager.ts @@ -15,9 +15,10 @@ import { statSync, } from "node:fs"; import { homedir } from "node:os"; -import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { basename, dirname, join, relative, resolve } from "node:path"; import { minimatch } from "minimatch"; import { isDefaultStateDir } from "../../config/paths.js"; +import { isPathInside } from "../../infra/path-guards.js"; import { addIgnoreRules, normalizeNativePathSeparators, @@ -550,13 +551,8 @@ function resolveRealPathIfPossible(path: string): string { } } -function isPathWithinRoot(root: string, candidate: string): boolean { - const rel = relative(root, candidate); - return rel === "" || (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)); -} - function isRealPathWithinRoot(root: string, candidate: string): boolean { - return isPathWithinRoot( + return isPathInside( resolveRealPathIfPossible(resolve(root)), resolveRealPathIfPossible(candidate), ); @@ -1209,10 +1205,10 @@ export class DefaultPackageManager implements PackageManager { const realRoot = resolveRealPathIfPossible(resolvedRoot); return paths.filter((path) => { const resolvedPath = resolve(path); - if (!isPathWithinRoot(resolvedRoot, resolvedPath)) { + if (!isPathInside(resolvedRoot, resolvedPath)) { return false; } - return isPathWithinRoot(realRoot, resolveRealPathIfPossible(resolvedPath)); + return isPathInside(realRoot, resolveRealPathIfPossible(resolvedPath)); }); } diff --git a/src/cli/gateway-cli/qa-parent-watchdog.ts b/src/cli/gateway-cli/qa-parent-watchdog.ts index c0bdc34b1e92..e2781b859365 100644 --- a/src/cli/gateway-cli/qa-parent-watchdog.ts +++ b/src/cli/gateway-cli/qa-parent-watchdog.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import { isPathInside } from "../../infra/path-guards.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; const QA_PARENT_PID_ENV = "OPENCLAW_QA_PARENT_PID"; @@ -70,14 +71,6 @@ function resolveQaCleanupRoots(env: NodeJS.ProcessEnv): string[] { ); } -function pathContains(root: string, candidate: string): boolean { - const relative = path.relative(root, candidate); - return ( - relative === "" || - (relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative)) - ); -} - export function installQaParentWatchdog( deps: QaParentWatchdogDeps = {}, ): QaParentWatchdogHandle | null { @@ -133,7 +126,7 @@ export function installQaParentWatchdog( void (async () => { const currentCwd = path.resolve(cwd()); const activeCwdRoot = qaCleanupRoots.find((cleanupRoot) => - pathContains(cleanupRoot, currentCwd), + isPathInside(cleanupRoot, currentCwd), ); if (activeCwdRoot) { const safeCwd = path.dirname(activeCwdRoot); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index d558899e5b16..c2286480bb5b 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -11,6 +11,7 @@ import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js"; import { CONFIG_PATH } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { callGateway } from "../gateway/call.js"; +import { isPathInside } from "../infra/path-guards.js"; import type { RuntimeEnv } from "../runtime.js"; import { noteImplicitFallbackClobberWarnings, @@ -49,10 +50,7 @@ function collectInvalidHookTransformsDirWarnings( const resolved = path.isAbsolute(transformsDir) ? path.resolve(transformsDir) : path.resolve(transformsRoot, transformsDir); - const relative = path.relative(transformsRoot, resolved); - const escapesRoot = - relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); - if (!escapesRoot) { + if (isPathInside(transformsRoot, resolved)) { return []; } return [ diff --git a/src/commands/doctor-session-sqlite.ts b/src/commands/doctor-session-sqlite.ts index d0509345ddab..c534e4e68b7f 100644 --- a/src/commands/doctor-session-sqlite.ts +++ b/src/commands/doctor-session-sqlite.ts @@ -20,6 +20,7 @@ import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveStoredSessionOwnerAgentId } from "../gateway/session-store-key.js"; import { readFileDescriptorBoundedSync } from "../infra/boundary-file-read.js"; +import { isPathInside } from "../infra/path-guards.js"; import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js"; import { normalizeLegacySessionEntryDelivery as normalizeSessionEntryDelivery } from "../infra/state-migrations.legacy-session-store.js"; import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; @@ -197,10 +198,7 @@ function resolveDoctorSessionSqliteMaintenanceRoots( } function isPathWithin(rootPath: string, candidatePath: string): boolean { - const relativePath = path.relative(rootPath, path.resolve(candidatePath)); - return ( - relativePath === "" || (!relativePath.startsWith(`..${path.sep}`) && relativePath !== "..") - ); + return isPathInside(rootPath, path.resolve(candidatePath)); } function commonPathAncestor(leftPath: string, rightPath: string): string { diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index 14a0e7526e94..2ac5807834d9 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -1822,71 +1822,5 @@ describe("ensureOnboardingPluginInstalled", () => { expect(captured?.options).toEqual([{ value: "skip", label: "Skip for now" }]); }); }); - - it("rejects local install paths when relative resolution looks cross-drive", async () => { - await withTestDir({ prefix: "openclaw-onboarding-install-cross-drive-" }, async (temp) => { - const workspaceDir = path.join(temp, "workspace"); - const pluginDir = path.join(workspaceDir, "plugins", "demo"); - await fs.mkdir(path.join(workspaceDir, ".git"), { recursive: true }); - await fs.mkdir(pluginDir, { recursive: true }); - const realWorkspaceDir = await fs.realpath(workspaceDir); - - const originalRelative = path.relative; - const originalIsAbsolute = path.isAbsolute; - const relativeSpy = vi.spyOn(path, "relative").mockImplementation((from, to) => { - if ( - typeof from === "string" && - typeof to === "string" && - from === realWorkspaceDir && - to === path.join(realWorkspaceDir, "plugins", "demo") - ) { - return "D:\\evil"; - } - return originalRelative(from, to); - }); - const isAbsoluteSpy = vi.spyOn(path, "isAbsolute").mockImplementation((value) => { - if (value === "D:\\evil") { - return true; - } - return originalIsAbsolute(value); - }); - - try { - let captured: - | { - options: Array<{ - value: "clawhub" | "npm" | "local" | "skip"; - label: string; - hint?: string; - }>; - } - | undefined; - - await ensureOnboardingPluginInstalled({ - cfg: {}, - entry: { - pluginId: "demo-plugin", - label: "Demo Plugin", - install: { - localPath: "plugins/demo", - }, - }, - prompter: { - select: vi.fn(async (input) => { - captured = input; - return "skip"; - }), - } as never, - runtime: {} as never, - workspaceDir, - }); - - expect(captured?.options).toEqual([{ value: "skip", label: "Skip for now" }]); - } finally { - relativeSpy.mockRestore(); - isAbsoluteSpy.mockRestore(); - } - }); - }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index 157aeec8baef..5b8108694991 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -15,6 +15,7 @@ import { assertConfigWriteAllowedInCurrentMode } from "../config/nix-mode-write- import type { OpenClawConfig } from "../config/types.openclaw.js"; import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; import { isOpenClawOrgNpmSpec, parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; +import { isPathInside } from "../infra/path-guards.js"; import { normalizeUpdateChannel, resolveRegistryUpdateChannel } from "../infra/update-channels.js"; import { findBundledPluginSourceInMap, @@ -175,14 +176,6 @@ function resolveGitDirectoryMarker(dir: string): string | null { } } -function isWithinBaseDirectory(baseDir: string, targetPath: string): boolean { - const relative = path.relative(baseDir, targetPath); - return ( - relative === "" || - (!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..") - ); -} - function hasTrustedGitWorkspace(root: string): boolean { const realRoot = resolveRealDirectory(root); if (!realRoot) { @@ -241,11 +234,8 @@ function formatPortableLocalPath(localPath: string, workspaceDir?: string): stri if (!realBase) { continue; } - const relative = path.relative(realBase, localPath); - if ( - relative === "" || - (!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..") - ) { + if (isPathInside(realBase, localPath)) { + const relative = path.relative(realBase, localPath); const portable = relative.split(path.sep).join("/"); return portable ? `./${portable}` : "."; } @@ -302,7 +292,7 @@ function resolveLocalPath(params: { if ( !bases.some((base) => { const realBase = resolveRealDirectory(base); - return realBase ? isWithinBaseDirectory(realBase, resolved) : false; + return realBase ? isPathInside(realBase, resolved) : false; }) ) { continue; diff --git a/src/fleet/backup.runtime.ts b/src/fleet/backup.runtime.ts index cebf78fb252e..cd4b4dfd53ab 100644 --- a/src/fleet/backup.runtime.ts +++ b/src/fleet/backup.runtime.ts @@ -15,6 +15,7 @@ import { import { createBackupLinkCache } from "../infra/backup-volatile-stat-cache.js"; import { formatErrorMessage as errorMessage } from "../infra/errors.js"; import { root as fsSafeRoot } from "../infra/fs-safe.js"; +import { isPathInside } from "../infra/path-guards.js"; import { cellAuthSecretDir, cellNetworkName, @@ -120,14 +121,6 @@ async function canonicalizeForContainment(targetPath: string): Promise { } } -function isWithin(candidate: string, root: string): boolean { - const relative = path.relative(root, candidate); - return ( - relative === "" || - (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) - ); -} - function remapArchivePath( entryPath: string, manifestPath: string, @@ -138,11 +131,11 @@ function remapArchivePath( if (resolved === manifestPath) { return "manifest.json"; } - if (isWithin(resolved, dataTarget)) { + if (isPathInside(dataTarget, resolved)) { const relative = path.relative(dataTarget, resolved).split(path.sep).join(path.posix.sep); return relative ? path.posix.join("data", relative) : "data"; } - if (isWithin(resolved, authTarget)) { + if (isPathInside(authTarget, resolved)) { const relative = path.relative(authTarget, resolved).split(path.sep).join(path.posix.sep); return relative ? path.posix.join("auth", relative) : "auth"; } @@ -206,7 +199,7 @@ export async function backupFleetCell(params: { ); const canonicalOutput = await canonicalizeForContainment(archivePath); const roots = [dataTarget, authTarget]; - if (roots.some((root) => isWithin(canonicalOutput, root))) { + if (roots.some((root) => isPathInside(root, canonicalOutput))) { throw new Error( "Fleet backup output must not be written inside the cell data or auth directory.", ); @@ -475,7 +468,7 @@ export async function restoreFleetCell(params: { canonicalizeForContainment(params.record.dataDir), canonicalizeForContainment(cellAuthSecretDir(params.stateDir, params.record.tenantId)), ]); - if (restoreRoots.some((root) => isWithin(canonicalArchive, root))) { + if (restoreRoots.some((root) => isPathInside(root, canonicalArchive))) { throw new Error( "Fleet restore archive must not be stored inside the cell data or auth directory.", ); diff --git a/src/flows/doctor-removed-workspaces-state-check.ts b/src/flows/doctor-removed-workspaces-state-check.ts index 2ef0c072ff2d..075634ee284e 100644 --- a/src/flows/doctor-removed-workspaces-state-check.ts +++ b/src/flows/doctor-removed-workspaces-state-check.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { listAgentEntries } from "../agents/agent-scope-config.js"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isPathInside } from "../infra/path-guards.js"; import { resolveUserPath } from "../utils.js"; import type { HealthCheck, HealthRepairEffect } from "./health-checks.js"; @@ -55,14 +56,6 @@ async function canonicalPath(target: string): Promise { } } -function isSameOrDescendant(parent: string, candidate: string): boolean { - const relative = path.relative(parent, candidate); - return ( - relative === "" || - (!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..") - ); -} - async function configuredAgentWorkspaceCollisions( cfg: OpenClawConfig, target: string, @@ -89,8 +82,8 @@ async function configuredAgentWorkspaceCollisions( return resolvedEntries .filter( (entry) => - isSameOrDescendant(resolvedTarget, entry.resolvedWorkspace) || - isSameOrDescendant(entry.resolvedWorkspace, resolvedTarget), + isPathInside(resolvedTarget, entry.resolvedWorkspace) || + isPathInside(entry.resolvedWorkspace, resolvedTarget), ) .map((entry) => entry.label); } diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 3a3da5b1dcf5..c973c067e756 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -9,6 +9,7 @@ import { import { resolveConfigPathCandidate } from "../config/paths.js"; import type { HookMappingConfig, HooksConfig, HookSessionMode } from "../config/types.hooks.js"; import { importFileModule, resolveFunctionModuleExport } from "../hooks/module-loader.js"; +import { isPathInside } from "../infra/path-guards.js"; import type { HookMessageChannel } from "./hooks.types.js"; export type HookMappingResolved = { @@ -444,11 +445,6 @@ function resolvePath(baseDir: string, target: string): string { return path.isAbsolute(target) ? path.resolve(target) : path.resolve(baseDir, target); } -function escapesBase(baseDir: string, candidate: string): boolean { - const relative = path.relative(baseDir, candidate); - return relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); -} - function safeRealpathSync(candidate: string): string | null { try { // Hook containment prefers native canonicalization when Node exposes it. @@ -481,7 +477,7 @@ function resolveContainedPath(baseDir: string, target: string, label: string): s throw new Error(`${label} module path is required`); } const resolved = resolvePath(base, trimmed); - if (escapesBase(base, resolved)) { + if (!isPathInside(base, resolved)) { throw new Error(`${label} module path must be within ${base}: ${target}`); } @@ -493,7 +489,7 @@ function resolveContainedPath(baseDir: string, target: string, label: string): s if ( baseRealpath && existingAncestorRealpath && - escapesBase(baseRealpath, existingAncestorRealpath) + !isPathInside(baseRealpath, existingAncestorRealpath) ) { throw new Error(`${label} module path must be within ${base}: ${target}`); } diff --git a/src/gateway/server-methods/sessions-files.ts b/src/gateway/server-methods/sessions-files.ts index c512f7951aea..b3c3d29402a3 100644 --- a/src/gateway/server-methods/sessions-files.ts +++ b/src/gateway/server-methods/sessions-files.ts @@ -24,6 +24,7 @@ import { runGit } from "../../agents/worktrees/git.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { FsSafeError } from "../../infra/fs-safe.js"; import { pruneMapToMaxSize } from "../../infra/map-size.js"; +import { isPathInside } from "../../infra/path-guards.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { @@ -313,14 +314,6 @@ function toDisplayPath(root: string, resolved: string): string { return relative.split(path.sep).join("/"); } -function isInsideRoot(root: string, candidate: string): boolean { - const relative = path.relative(root, candidate); - return ( - relative === "" || - (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) - ); -} - function resolveTouchedFilePath(params: { root: string | undefined; fileRoot: string | undefined; @@ -331,7 +324,7 @@ function resolveTouchedFilePath(params: { } const base = params.fileRoot ?? params.root; const resolved = resolveSessionToolPathToCwd(params.filePath, base); - if (!isInsideRoot(params.root, resolved)) { + if (!isPathInside(params.root, resolved)) { return undefined; } return resolved; @@ -349,7 +342,7 @@ function resolveFileRoot(params: { } const resolvedCwd = path.resolve(params.spawnedCwd); const resolvedRoot = path.resolve(params.root); - return isInsideRoot(resolvedRoot, resolvedCwd) ? params.spawnedCwd : params.root; + return isPathInside(resolvedRoot, resolvedCwd) ? params.spawnedCwd : params.root; } function relevanceForKind(kind: FileKind): SessionFileRelevance { diff --git a/src/infra/path-env.test.ts b/src/infra/path-env.test.ts index 730507fd5ae0..331f83277f35 100644 --- a/src/infra/path-env.test.ts +++ b/src/infra/path-env.test.ts @@ -505,33 +505,43 @@ describe("ensureOpenClawCliOnPath", () => { expect(updated).not.toContain(path.join("npm-prefix", "bin")); }); - it("ignores package-manager env roots derived from the active workspace", () => { - const homeDir = abs("/tmp/openclaw-path/home"); - const cwd = path.join(homeDir, "workspace"); - const appBinDir = path.join(homeDir, "app-bin"); - const appCli = path.join(appBinDir, "openclaw"); - const pnpmHome = path.join(cwd, ".pnpm"); - const npmPrefix = path.join(cwd, ".npm-prefix"); - for (const dir of [homeDir, cwd, appBinDir, pnpmHome, path.join(pnpmHome, "bin"), npmPrefix]) { - setDir(dir); - } - setDir(path.join(npmPrefix, "bin")); - setExe(appCli); - resetBootstrapEnv("/usr/bin:/bin"); - process.env.PNPM_HOME = pnpmHome; - process.env.NPM_CONFIG_PREFIX = npmPrefix; + it.each([".pnpm", "..cache"])( + "ignores package-manager env roots derived from the active workspace (%s)", + (packageManagerDir) => { + const homeDir = abs("/tmp/openclaw-path/home"); + const cwd = path.join(homeDir, "workspace"); + const appBinDir = path.join(homeDir, "app-bin"); + const appCli = path.join(appBinDir, "openclaw"); + const pnpmHome = path.join(cwd, packageManagerDir); + const npmPrefix = path.join(cwd, ".npm-prefix"); + for (const dir of [ + homeDir, + cwd, + appBinDir, + pnpmHome, + path.join(pnpmHome, "bin"), + npmPrefix, + ]) { + setDir(dir); + } + setDir(path.join(npmPrefix, "bin")); + setExe(appCli); + resetBootstrapEnv("/usr/bin:/bin"); + process.env.PNPM_HOME = pnpmHome; + process.env.NPM_CONFIG_PREFIX = npmPrefix; - const updated = bootstrapPath({ - execPath: appCli, - cwd, - homeDir, - platform: "linux", - }); + const updated = bootstrapPath({ + execPath: appCli, + cwd, + homeDir, + platform: "linux", + }); - expect(updated).not.toContain(pnpmHome); - expect(updated).not.toContain(path.join(pnpmHome, "bin")); - expect(updated).not.toContain(path.join(npmPrefix, "bin")); - }); + expect(updated).not.toContain(pnpmHome); + expect(updated).not.toContain(path.join(pnpmHome, "bin")); + expect(updated).not.toContain(path.join(npmPrefix, "bin")); + }, + ); it("ignores package-manager env roots whose existing parent resolves into the workspace", () => { const homeDir = abs("/tmp/openclaw-path/home"); diff --git a/src/infra/path-env.ts b/src/infra/path-env.ts index c76fbcfe1aec..bc18d60647ef 100644 --- a/src/infra/path-env.ts +++ b/src/infra/path-env.ts @@ -8,6 +8,7 @@ import { } from "@openclaw/normalization-core/string-normalization"; import { resolveBrewPathDirs } from "./brew.js"; import { isTruthyEnvValue } from "./env.js"; +import { isPathInside } from "./path-guards.js"; import { tryProcessCwd } from "./safe-cwd.js"; type EnsureOpenClawPathOpts = { @@ -67,11 +68,6 @@ function realpathExistingPath(candidate: string): string | undefined { } } -function isSameOrChildPath(candidate: string, parent: string): boolean { - const relative = path.relative(parent, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - function isFilesystemRoot(dirPath: string): boolean { return path.dirname(dirPath) === dirPath; } @@ -98,7 +94,7 @@ function normalizeTrustedPackageManagerRoot(params: { if (cwd === homeDir || isFilesystemRoot(cwd)) { return normalized; } - if (isSameOrChildPath(normalized, cwd)) { + if (isPathInside(cwd, normalized)) { return undefined; } @@ -110,7 +106,7 @@ function normalizeTrustedPackageManagerRoot(params: { realCwd !== realHome && !isFilesystemRoot(realCwd) && realCandidate && - isSameOrChildPath(realCandidate, realCwd) + isPathInside(realCwd, realCandidate) ) { return undefined; } diff --git a/src/infra/path-guards.test.ts b/src/infra/path-guards.test.ts index 951e637bb80b..7e4f5ea3e2f4 100644 --- a/src/infra/path-guards.test.ts +++ b/src/infra/path-guards.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { mockProcessPlatform } from "../test-utils/vitest-spies.js"; import { isPathInside, + isPathStrictlyInside, normalizeWindowsPathForComparison, normalizeWindowsPathPreservingCase, } from "./path-guards.js"; @@ -60,6 +61,7 @@ describe("isPathInside", () => { ["/workspace/root", "/workspace/root", true], ["/workspace/root", "/workspace/root/nested/file.txt", true], ["/workspace/root", "/workspace/root/..file.txt", true], + ["/workspace/root", "/workspace/root/..cache/cache.json", true], ["/workspace/root", "/workspace/root/../escape.txt", false], ["/workspace/root", "/workspace/rootless/file.txt", false], ["/workspace/root", "/workspace/root/a/b/c/d/e/file.txt", true], @@ -88,3 +90,26 @@ describe("isPathInside", () => { } }); }); + +describe("isPathStrictlyInside", () => { + it.each([ + ["/workspace/root", "/workspace/root", false], + ["/workspace/root", "/workspace/root/child", true], + ["/workspace/root", "/workspace/root/..cache/cache.json", true], + ["/workspace/root", "/workspace/root/../escape", false], + ])("checks strict posix containment %s -> %s", (basePath, targetPath, expected) => { + expect(isPathStrictlyInside(basePath, targetPath)).toBe(expected); + }); + + it("uses win32 path semantics for strict containment checks", () => { + setPlatform("win32"); + + for (const [basePath, targetPath, expected] of [ + [String.raw`C:\workspace\root`, String.raw`C:\workspace\root`, false], + [String.raw`C:\workspace\root`, String.raw`C:\workspace\root\..cache\file.txt`, true], + [String.raw`C:\workspace\root`, String.raw`D:\workspace\root\file.txt`, false], + ] as const) { + expect(isPathStrictlyInside(basePath, targetPath)).toBe(expected); + } + }); +}); diff --git a/src/infra/path-guards.ts b/src/infra/path-guards.ts index 3560a380d2c0..71d8501fd8e1 100644 --- a/src/infra/path-guards.ts +++ b/src/infra/path-guards.ts @@ -1,15 +1,21 @@ // Exposes generic path guard helpers with fs-safe defaults. import path from "node:path"; +import { isPathInside } from "@openclaw/fs-safe/path"; import "./fs-safe-defaults.js"; // Generic path guard facade for containment checks and safe relative paths. export { hasNodeErrorCode, isNotFoundPathError, - isPathInside, normalizeWindowsPathForComparison, safeStatSync, } from "@openclaw/fs-safe/path"; +export { isPathInside }; + +/** Returns true only when target is a descendant of root, not root itself. */ +export function isPathStrictlyInside(root: string, target: string): boolean { + return isPathInside(root, target) && !isPathInside(target, root); +} /** * Normalize a Windows path for boundary math whose result is handed back to callers. diff --git a/src/media/web-media.ts b/src/media/web-media.ts index 8a695f2cad59..0876fc4d305e 100644 --- a/src/media/web-media.ts +++ b/src/media/web-media.ts @@ -25,7 +25,7 @@ import { } from "../infra/kysely-sync.js"; import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../infra/local-file-access.js"; import type { PinnedDispatcherPolicy, SsrFPolicy } from "../infra/net/ssrf.js"; -import { isNotFoundPathError } from "../infra/path-guards.js"; +import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { getActivePluginHttpRouteRegistry } from "../plugins/runtime.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; @@ -300,19 +300,9 @@ function getValidatedHostReadText(buffer?: Buffer): string | undefined { return printableRatio > 0.95 ? text : undefined; } -function isPathInsideRoot(filePath: string | undefined, root: string): boolean { - if (!filePath) { - return false; - } - const relative = path.relative(path.resolve(root), path.resolve(filePath)); - return ( - relative === "" || (relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)) - ); -} - function resolveLocalMediaFileName(filePath: string): string | undefined { const fileName = basenameFromAnyPath(filePath) || undefined; - return fileName && isPathInsideRoot(filePath, getMediaDir()) + return fileName && isPathInside(getMediaDir(), filePath) ? extractOriginalFilename(fileName) : fileName; } @@ -375,15 +365,13 @@ async function resolveTrustedGeneratedHostReadHtml( } // Outbound staging always requires provenance, even when a custom state dir // places media/outbound underneath the otherwise trusted temp root. - if (outboundRoot && isPathInsideRoot(resolvedFilePath, outboundRoot)) { + if (outboundRoot && isPathInside(outboundRoot, resolvedFilePath)) { const marker = await getTrustedGeneratedHtmlMarker(resolvedFilePath); return marker ? { source: "outbound", expectedSha256: marker.sha256, expectedSize: marker.size } : undefined; } - return tmpRoot && isPathInsideRoot(resolvedFilePath, tmpRoot) - ? { source: "temp-root" } - : undefined; + return tmpRoot && isPathInside(tmpRoot, resolvedFilePath) ? { source: "temp-root" } : undefined; } /** Records exact-byte provenance for a trusted generated HTML staged outbound. */ @@ -393,7 +381,7 @@ export async function markTrustedGeneratedHtmlPath( ): Promise { const resolvedFilePath = await realpath(filePath); const outboundRoot = await realpath(path.join(getMediaDir(), "outbound")).catch(() => undefined); - if (!outboundRoot || !isPathInsideRoot(resolvedFilePath, outboundRoot)) { + if (!outboundRoot || !isPathInside(outboundRoot, resolvedFilePath)) { throw new Error( `markTrustedGeneratedHtmlPath: refusing path outside outbound staging: ${resolvedFilePath}`, ); diff --git a/src/plugins/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts index 59ab035f9239..20cc4af72c8f 100644 --- a/src/plugins/bundle-mcp.test.ts +++ b/src/plugins/bundle-mcp.test.ts @@ -567,6 +567,7 @@ describe("loadEnabledBundleMcpConfig", () => { $schema: AGENT_MCP_SCHEMA, mcpServers: { valid: { type: "stdio", command: "node", cwd: "./child" }, + dotPrefixedChild: { type: "stdio", command: "node", cwd: "./..cache" }, relativeEscape: { type: "stdio", command: "node", cwd: "./../escape" }, placeholderEscape: { type: "stdio", @@ -575,7 +576,7 @@ describe("loadEnabledBundleMcpConfig", () => { }, }, }, - textFiles: { "child/.keep": "" }, + textFiles: { "child/.keep": "", "..cache/.keep": "" }, }); const rootRealPath = await fs.realpath(pluginRoot); const relativeProcessCwd = path.relative(rootRealPath, await fs.realpath(process.cwd())); @@ -588,11 +589,15 @@ describe("loadEnabledBundleMcpConfig", () => { cfg: createEnabledBundleConfig(["portable-cwd"]), }); - expect(Object.keys(loaded.config.mcpServers)).toEqual(["valid"]); + expect(Object.keys(loaded.config.mcpServers)).toEqual(["valid", "dotPrefixedChild"]); await expectResolvedPathEqual( loaded.config.mcpServers.valid?.cwd, path.join(pluginRoot, "child"), ); + await expectResolvedPathEqual( + loaded.config.mcpServers.dotPrefixedChild?.cwd, + path.join(pluginRoot, "..cache"), + ); expect(loaded.diagnostics).toHaveLength(2); expect(loaded.diagnostics.map((entry) => entry.message)).toEqual([ expect.stringContaining('invalid MCP server "relativeEscape"'), diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index 7f78ef4d4748..e0dc74a667ca 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -6,6 +6,7 @@ import { resolveMcpTransportConfig } from "../agents/mcp-transport-config.js"; import { applyMergePatch } from "../config/merge-patch.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { readRootJsonObjectSync } from "../infra/json-files.js"; +import { isPathInside } from "../infra/path-guards.js"; import { isRecord } from "../utils.js"; import { loadEnabledBundleConfig, @@ -240,20 +241,12 @@ function hasOnlyKeys(raw: Record, allowed: ReadonlySet) return Object.keys(raw).every((key) => allowed.has(key)); } -function isPathWithin(baseDir: string, targetPath: string): boolean { - const relative = path.relative(baseDir, targetPath); - return ( - relative === "" || - (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)) - ); -} - function isValidAgentCommand(command: unknown, rootDir: string): command is string { if (typeof command !== "string" || command.length === 0) { return false; } if (command.startsWith("./")) { - return command.length > 2 && isPathWithin(rootDir, path.resolve(rootDir, command)); + return command.length > 2 && isPathInside(rootDir, path.resolve(rootDir, command)); } return !/[\s/\\]/.test(command); } @@ -279,7 +272,7 @@ function isValidAgentCwd(cwd: unknown, rootDir: string, pluginDataDir: string): return false; } const expanded = expandBundleRootPlaceholders({ value: cwd, rootDir, pluginDataDir }); - return isPathWithin(baseDir, path.resolve(baseDir, expanded)); + return isPathInside(baseDir, path.resolve(baseDir, expanded)); } function validateAgentMcpServer(params: { diff --git a/src/plugins/bundled-plugin-metadata.ts b/src/plugins/bundled-plugin-metadata.ts index ca912756c73a..8b2063e514d9 100644 --- a/src/plugins/bundled-plugin-metadata.ts +++ b/src/plugins/bundled-plugin-metadata.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { tryReadJsonSync } from "../infra/json-files.js"; +import { isPathInside } from "../infra/path-guards.js"; import { collectBundledChannelConfigsCore } from "./bundled-channel-config-metadata.js"; import { collectBundledPluginPublicSurfaceArtifacts, @@ -218,11 +219,6 @@ function listBundledPluginEntryBaseDirs(params: { return uniqueStrings(baseDirs); } -function isPathInsideRoot(rootDir: string, targetPath: string): boolean { - const relative = path.relative(rootDir, targetPath); - return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); -} - function listBundledPluginEntryRoots(params: { rootDir: string; pluginDirName?: string; @@ -257,7 +253,7 @@ function listBundledPluginEntrySearchPaths( } const normalizedEntry = path.normalize(rawEntry); for (const root of roots) { - if (!isPathInsideRoot(root, normalizedEntry)) { + if (!isPathInside(root, normalizedEntry)) { continue; } const relativeEntry = path.relative(root, normalizedEntry); @@ -314,8 +310,7 @@ function resolveBundledPluginEntryCandidate(baseDir: string, entryPath: string): const candidate = path.isAbsolute(normalizedEntryPath) ? path.normalize(normalizedEntryPath) : path.resolve(baseDir, normalizedEntryPath); - const relative = path.relative(baseDir, candidate); - if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + if (!isPathInside(baseDir, candidate)) { return null; } return candidate; diff --git a/src/plugins/installed-plugin-package-ownership.ts b/src/plugins/installed-plugin-package-ownership.ts index 509cf7cb9d32..141bdf6386b8 100644 --- a/src/plugins/installed-plugin-package-ownership.ts +++ b/src/plugins/installed-plugin-package-ownership.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { isPathInside } from "../infra/path-guards.js"; import { resolveUserPath } from "../utils.js"; import { isInstalledPluginIndexInstallOwnerAmbiguous, @@ -127,8 +128,7 @@ function installRecordPathMatchesPluginRoot( } const candidatePath = path.resolve(resolveUserPath(candidate, env)); const resolvedCandidate = safeRealpathSync(candidatePath, realpathCache) ?? candidatePath; - const relative = path.relative(resolvedCandidate, resolvedRoot); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + return isPathInside(resolvedCandidate, resolvedRoot); }); } diff --git a/src/plugins/managed-npm-retention.ts b/src/plugins/managed-npm-retention.ts index 49646486de91..2711a9b3506b 100644 --- a/src/plugins/managed-npm-retention.ts +++ b/src/plugins/managed-npm-retention.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { safePathSegmentHashed } from "../infra/install-safe-path.js"; +import { isPathInside } from "../infra/path-guards.js"; import { isPluginNpmProjectDir, resolveDefaultPluginNpmDir, @@ -126,11 +127,6 @@ export async function markRetainedManagedNpmInstall(params: { return true; } -function isPathEqualOrInside(parentPath: string, childPath: string): boolean { - const relative = path.relative(path.resolve(parentPath), path.resolve(childPath)); - return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`)); -} - function listManagedNpmPackageDirs(npmRoot: string): string[] { const nodeModulesDir = path.join(npmRoot, "node_modules"); let entries: fs.Dirent[]; @@ -185,7 +181,7 @@ async function cleanupRetainedLegacyNpmPackages(params: { if ( !hasRetainedManagedNpmInstallMarker(packageDir) || markerPreservesPackageFiles(resolveRetainedManagedNpmInstallMarkerPath(packageDir)) || - params.activeInstallPaths.some((installPath) => isPathEqualOrInside(packageDir, installPath)) + params.activeInstallPaths.some((installPath) => isPathInside(packageDir, installPath)) ) { continue; } @@ -243,13 +239,13 @@ export async function cleanupRetainedManagedNpmInstallGenerations( markerEntries.some((entry) => markerPreservesPackageFiles(path.join(markerDir, entry.name)), ) || - !isPathEqualOrInside(projectsDir, projectRoot) || + !isPathInside(projectsDir, projectRoot) || !isOwnedManagedNpmProject({ markerNames: new Set(markerEntries.map((entry) => entry.name)), npmDir, projectRoot, }) || - activeInstallPaths.some((installPath) => isPathEqualOrInside(projectRoot, installPath)) + activeInstallPaths.some((installPath) => isPathInside(projectRoot, installPath)) ) { continue; } diff --git a/src/plugins/marketplace.ts b/src/plugins/marketplace.ts index 9f2a47af86db..f4e32923821b 100644 --- a/src/plugins/marketplace.ts +++ b/src/plugins/marketplace.ts @@ -957,8 +957,7 @@ async function downloadUrlToTempFile( tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-download-")); const createdTmpDir = tmpDir; const targetPath = path.resolve(createdTmpDir, fileName); - const relativeTargetPath = path.relative(createdTmpDir, targetPath); - if (relativeTargetPath === ".." || relativeTargetPath.startsWith(`..${path.sep}`)) { + if (!isPathInside(createdTmpDir, targetPath)) { throw new Error("invalid download filename"); } await streamMarketplaceResponseToFile({ @@ -995,8 +994,7 @@ async function ensureInsideMarketplaceRoot( ): Promise<{ ok: true; path: string } | { ok: false; error: string }> { const resolved = path.resolve(rootDir, candidate); const resolvedExists = await pathExists(resolved); - const relative = path.relative(rootDir, resolved); - if (relative === ".." || relative.startsWith(`..${path.sep}`)) { + if (!isPathInside(rootDir, resolved)) { return { ok: false, error: `plugin source escapes marketplace root: ${candidate}`, diff --git a/src/plugins/native-module-require.ts b/src/plugins/native-module-require.ts index 6d21f4ecbefb..863847fc8706 100644 --- a/src/plugins/native-module-require.ts +++ b/src/plugins/native-module-require.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import Module, { createRequire } from "node:module"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { isPathInside } from "../infra/path-guards.js"; const nodeRequire = createRequire(import.meta.url); type ResolveFilename = ( @@ -130,7 +131,7 @@ function clearRequireCacheSubtree( const cached = nodeRequire.cache[resolvedPath]; if (cached) { for (const child of cached.children) { - if (isPathInsideOrSame(dependencyRoot, child.id)) { + if (isPathInside(dependencyRoot, child.id)) { clearRequireCacheSubtree(child.id, dependencyRoot, seen); } } @@ -138,11 +139,6 @@ function clearRequireCacheSubtree( delete nodeRequire.cache[resolvedPath]; } -function isPathInsideOrSame(root: string, target: string): boolean { - const relative = path.relative(root, target); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - function requireWithOptionalAliases( modulePath: string, aliasMap: Record | undefined, diff --git a/src/plugins/plugin-sdk-native-resolver.ts b/src/plugins/plugin-sdk-native-resolver.ts index 86c5279e5020..61a9fcdc4dc6 100644 --- a/src/plugins/plugin-sdk-native-resolver.ts +++ b/src/plugins/plugin-sdk-native-resolver.ts @@ -4,6 +4,7 @@ import Module from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { resolveRealpathOrAbsolute } from "../infra/boundary-path.js"; +import { isPathInside, isPathStrictlyInside } from "../infra/path-guards.js"; import { PluginLruCache } from "./plugin-cache-primitives.js"; import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js"; import { @@ -163,10 +164,10 @@ function findBundledPluginRoot(modulePath: string): string | undefined { const packageRoot = normalizePathForBoundary(resolveLoaderPackageRootFromModulePath(modulePath)); for (const relativeRoot of ["extensions", "dist/extensions", "dist-runtime/extensions"]) { const bundledRoot = path.join(packageRoot, relativeRoot); - const relative = path.relative(bundledRoot, resolvedModulePath); - if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + if (!isPathStrictlyInside(bundledRoot, resolvedModulePath)) { continue; } + const relative = path.relative(bundledRoot, resolvedModulePath); const [pluginId] = relative.split(path.sep); if (pluginId) { return path.join(bundledRoot, pluginId); @@ -237,8 +238,7 @@ function resolveAllowedParentRoots( } function isWithinRoot(candidate: string, root: string): boolean { - const relative = path.relative(root, normalizePathForBoundary(candidate)); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + return isPathInside(root, normalizePathForBoundary(candidate)); } function resolveAliasTargetForParent( diff --git a/src/plugins/uninstall-package-config.ts b/src/plugins/uninstall-package-config.ts index 2d832e378336..14d6b68d636d 100644 --- a/src/plugins/uninstall-package-config.ts +++ b/src/plugins/uninstall-package-config.ts @@ -1,6 +1,7 @@ import { realpathSync } from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isPathInside } from "../infra/path-guards.js"; import { resetPluginSlotsToDefaults } from "./slots.js"; export type PluginConfigUninstallActions = { @@ -39,11 +40,10 @@ export function resolveComparableUninstallPathInternal(value: string): string { } export function isUninstallPathInsideOrEqualInternal(parent: string, child: string): boolean { - const relative = path.relative( + return isPathInside( resolveComparableUninstallPathInternal(parent), resolveComparableUninstallPathInternal(child), ); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } export function resolveUninstallChannelConfigKeysInternal( diff --git a/src/secrets/provider-integrations.ts b/src/secrets/provider-integrations.ts index d45fb753546b..c0c59b163b12 100644 --- a/src/secrets/provider-integrations.ts +++ b/src/secrets/provider-integrations.ts @@ -7,6 +7,7 @@ import type { ManualExecSecretProviderConfig, PluginIntegrationSecretProviderConfig, } from "../config/types.secrets.js"; +import { isPathInside } from "../infra/path-guards.js"; import { normalizePluginsConfig, type NormalizedPluginsConfig } from "../plugins/config-state.js"; import { shouldRejectHardlinkedPluginFiles } from "../plugins/hardlink-policy.js"; import { isActivatedManifestOwner } from "../plugins/manifest-owner-policy.js"; @@ -38,17 +39,9 @@ type SecretProviderIntegrationResolution = const NODE_COMMAND_PLACEHOLDER = "${node}"; const PLUGIN_INTEGRATION_PROVIDER_ID_MAX_LENGTH = 128; -function isPathInsideOrEqual(rootDir: string, candidate: string): boolean { - const relative = path.relative(path.resolve(rootDir), path.resolve(candidate)); - return ( - relative === "" || - (relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative)) - ); -} - function resolvePluginRelativePath(value: string, pluginRoot: string): string | undefined { const resolved = path.resolve(pluginRoot, value); - return isPathInsideOrEqual(pluginRoot, resolved) ? resolved : undefined; + return isPathInside(pluginRoot, resolved) ? resolved : undefined; } function isPluginRelativeEntrypoint(value: string): boolean { @@ -85,13 +78,13 @@ function isSecurePosixPathStat(stat: fs.Stats): boolean { } function pathSegmentsBetween(rootDir: string, targetDir: string): string[] | undefined { + if (!isPathInside(rootDir, targetDir)) { + return undefined; + } const relative = path.relative(rootDir, targetDir); if (relative === "") { return []; } - if (relative.startsWith("..") || path.isAbsolute(relative)) { - return undefined; - } return relative.split(path.sep).filter(Boolean); } @@ -182,7 +175,7 @@ function resolveNodeEntrypointArg(params: { } try { const realpath = fs.realpathSync(resolved); - if (!isPathInsideOrEqual(pluginRootRealpath, realpath)) { + if (!isPathInside(pluginRootRealpath, realpath)) { return undefined; } if ( diff --git a/src/skills/lifecycle/clawhub-status.ts b/src/skills/lifecycle/clawhub-status.ts index 9831e203a5c2..884cd6a77e90 100644 --- a/src/skills/lifecycle/clawhub-status.ts +++ b/src/skills/lifecycle/clawhub-status.ts @@ -8,6 +8,7 @@ import { type ClawHubSkillsShTrustState, } from "../../infra/clawhub-skills.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { isPathInside } from "../../infra/path-guards.js"; import { normalizeTrackedSkillSlug, resolveWorkspaceSkillInstallDir } from "./archive-install.js"; import { normalizeDownloadedArtifactLock, @@ -251,14 +252,6 @@ export function resolveClawHubSkillStatusLinkSync(params: { }; } -function isPathInsideDir(child: string, parent: string): boolean { - const relative = path.relative(parent, child); - return ( - relative === "" || - (relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative)) - ); -} - function readLocalSkillCardSync( skillDir: string, includeContent = false, @@ -277,7 +270,7 @@ function readLocalSkillCardSync( try { const rootRealPath = fsSync.realpathSync.native(skillDir); const cardRealPath = fsSync.realpathSync.native(cardPath); - if (!isPathInsideDir(cardRealPath, rootRealPath)) { + if (!isPathInside(rootRealPath, cardRealPath)) { return undefined; } fd = fsSync.openSync(cardPath, fsSync.constants.O_RDONLY | (fsSync.constants.O_NOFOLLOW ?? 0)); diff --git a/src/skills/runtime/refresh.ts b/src/skills/runtime/refresh.ts index 2f5a2d792ed7..384299c96ddc 100644 --- a/src/skills/runtime/refresh.ts +++ b/src/skills/runtime/refresh.ts @@ -6,6 +6,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import chokidar, { type FSWatcher } from "chokidar"; import { isDefaultStateDir } from "../../config/paths.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { isPathInside } from "../../infra/path-guards.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { CONFIG_DIR, resolveUserPath } from "../../utils.js"; import { resolvePluginSkillDirs } from "../loading/plugin-skills.js"; @@ -366,13 +367,6 @@ function watchDepthForPath(raw: string, depth: number): number { return depth + missingSegments; } -function isPathInside(parent: string, child: string): boolean { - const relative = path.relative(parent, child); - return ( - relative === "" || (relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)) - ); -} - function isPathInsideAnyRoot(roots: readonly string[], child: string): boolean { return roots.some((root) => isPathInside(root, child)); } diff --git a/src/wizard/setup.migration-promotion.ts b/src/wizard/setup.migration-promotion.ts index 0339889a71a4..2461e59d4a55 100644 --- a/src/wizard/setup.migration-promotion.ts +++ b/src/wizard/setup.migration-promotion.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { readDurableJsonFile, writeJsonAtomic } from "../infra/json-files.js"; -import { isNotFoundPathError } from "../infra/path-guards.js"; +import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js"; import type { MigrationApplyResult, MigrationPlan } from "../plugins/types.js"; import { hashSetupMigrationConfig } from "./setup.migration-canonical.js"; import { SetupMigrationTargetChangedError } from "./setup.migration-snapshot.js"; @@ -452,14 +452,6 @@ async function canonicalizePromotionPath( } } -function pathsOverlap(left: string, right: string): boolean { - const relative = path.relative(left, right); - return ( - relative.length === 0 || - (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) - ); -} - export async function assertSupportedStagedStateTree(params: { stagedStateDir: string; agentId: string; @@ -518,7 +510,7 @@ export async function assertDisjointPromotionTargets( }; const currentPath = normalizePath(current.path.path); const otherPath = normalizePath(other.path.path); - if (pathsOverlap(currentPath, otherPath) || pathsOverlap(otherPath, currentPath)) { + if (isPathInside(currentPath, otherPath) || isPathInside(otherPath, currentPath)) { throw new Error( `Migration promotion targets overlap: ${current.component.finalPath} and ${other.component.finalPath}.`, );