diff --git a/package-lock.json b/package-lock.json index cb445c5..40b7d44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5991,7 +5991,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/lib/agent-state/local.ts b/src/lib/agent-state/local.ts index ac3db49..d43493a 100644 --- a/src/lib/agent-state/local.ts +++ b/src/lib/agent-state/local.ts @@ -83,10 +83,14 @@ export const restoreAgentStateLocally = (params: { } const base = resolveStateDir(); + const trashRoot = path.join(base, "trash", "studio-delete-agent"); if (!fs.existsSync(trashDirRaw)) { throw new Error(`trashDir does not exist: ${trashDirRaw}`); } - const { resolvedCandidate: resolvedTrashDir } = ensureUnderBase(base, trashDirRaw); + // Validate trashDir is strictly under the expected trash root, not just anywhere under base. + // This prevents path traversal where an attacker could reference legitimate directories + // (e.g. base/agents/) as a "trashDir" and cause unintended file moves. + const { resolvedCandidate: resolvedTrashDir } = ensureUnderBase(trashRoot, trashDirRaw); const moves: GatewayAgentStateMove[] = []; const restoreIfExists = (src: string, dest: string) => { diff --git a/src/lib/skills/remove-local.ts b/src/lib/skills/remove-local.ts index 8773c56..4db2b32 100644 --- a/src/lib/skills/remove-local.ts +++ b/src/lib/skills/remove-local.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveUserPath } from "@/lib/clawdbot/paths"; +import { resolveStateDir, resolveUserPath } from "@/lib/clawdbot/paths"; import type { RemovableSkillSource, SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types"; const resolveComparablePath = (input: string): string => { @@ -57,6 +57,18 @@ export const removeSkillLocally = (params: SkillRemoveRequest): SkillRemoveResul const workspaceDir = normalizeRequiredPath(params.workspaceDir, "workspaceDir"); const managedSkillsDir = normalizeRequiredPath(params.managedSkillsDir, "managedSkillsDir"); + // Security: validate that workspaceDir and managedSkillsDir are under the + // server-side state directory. Without this check, an attacker can supply + // arbitrary values for these fields, effectively controlling the "allowed root" + // and bypassing the isPathInside containment check below. + const stateDir = resolveStateDir(); + if (!isPathInside(stateDir, workspaceDir)) { + throw new Error(`workspaceDir is not under the state directory: ${workspaceDir}`); + } + if (!isPathInside(stateDir, managedSkillsDir)) { + throw new Error(`managedSkillsDir is not under the state directory: ${managedSkillsDir}`); + } + const allowedRoot = resolveAllowedRoot({ source, workspaceDir,