From bf5a7a05ddb845de6040cf70b1f2f51906f9395e Mon Sep 17 00:00:00 2001 From: Tarun Sukhani Date: Thu, 5 Feb 2026 18:43:05 +0000 Subject: [PATCH] sandbox: scope skill loading to workspace for sandboxed agents Prevents managed/bundled skill file paths from leaking into sandboxed agent skill snapshots, which caused 'path escapes sandbox root' errors. Adds scopeToWorkspace option to loadSkillEntries/buildWorkspaceSkillSnapshot. Also fixes stale Docker mount detection on container probe failure. --- src/agents/sandbox/docker.ts | 19 +++++++++++ src/agents/skills/workspace.ts | 33 ++++++++++++++++++- .../reply/commands-context-report.ts | 9 ++--- src/auto-reply/reply/session-updates.ts | 8 +++++ src/commands/agent.ts | 5 +++ 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index f87f7d5f5b4..b17733a667c 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -442,6 +442,25 @@ export async function ensureSandboxContainer(params: { }); } else if (!running) { await execDocker(["start", containerName]); + } else { + // Container was already running – verify the workspace bind mount is still + // valid. When the host directory backing the mount is deleted while the + // container is running, any `docker exec` against it fails with an OCI + // "mount namespace" error. Detect this and recreate the container. + const probe = await execDocker(["exec", containerName, "true"], { allowFailure: true }); + if (probe.code !== 0 && probe.stderr.includes("mount namespace")) { + defaultRuntime.log(`Sandbox mount stale for ${containerName}; recreating.`); + await execDocker(["rm", "-f", containerName], { allowFailure: true }); + await createSandboxContainer({ + name: containerName, + cfg: params.cfg.docker, + workspaceDir: params.workspaceDir, + workspaceAccess: params.cfg.workspaceAccess, + agentWorkspaceDir: params.agentWorkspaceDir, + scopeKey, + configHash: expectedHash, + }); + } } await updateRegistry({ containerName, diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index 000b7b6424e..2f12677c1e3 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -108,6 +108,8 @@ function loadSkillEntries( config?: OpenClawConfig; managedSkillsDir?: string; bundledSkillsDir?: string; + /** When true, only load skills from the workspace dir (skip managed/bundled/extra). */ + scopeToWorkspace?: boolean; }, ): SkillEntry[] { const loadSkills = (params: { dir: string; source: string }): Skill[] => { @@ -126,8 +128,34 @@ function loadSkillEntries( return []; }; + const workspaceSkillsDir = path.join(workspaceDir, "skills"); + + // When scoped to workspace, only load skills from the workspace dir. + // This prevents managed/bundled skill paths from leaking into sandboxed + // agents where those paths are outside the sandbox root. + if (opts?.scopeToWorkspace) { + const workspaceSkills = loadSkills({ + dir: workspaceSkillsDir, + source: "openclaw-workspace", + }); + return workspaceSkills.map((skill) => { + let frontmatter: ParsedSkillFrontmatter = {}; + try { + const raw = fs.readFileSync(skill.filePath, "utf-8"); + frontmatter = parseFrontmatter(raw); + } catch { + // ignore malformed skills + } + return { + skill, + frontmatter, + metadata: resolveOpenClawMetadata(frontmatter), + invocation: resolveSkillInvocationPolicy(frontmatter), + }; + }); + } + const managedSkillsDir = opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills"); - const workspaceSkillsDir = path.resolve(workspaceDir, "skills"); const bundledSkillsDir = opts?.bundledSkillsDir ?? resolveBundledSkillsDir(); const extraDirsRaw = opts?.config?.skills?.load?.extraDirs ?? []; const extraDirs = extraDirsRaw @@ -221,6 +249,8 @@ export function buildWorkspaceSkillSnapshot( skillFilter?: string[]; eligibility?: SkillEligibilityContext; snapshotVersion?: number; + /** When true, only load skills from the workspace dir (for sandboxed agents). */ + scopeToWorkspace?: boolean; }, ): SkillSnapshot { const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts); @@ -356,6 +386,7 @@ export async function syncSkillsToWorkspace(params: { }) { const sourceDir = resolveUserPath(params.sourceWorkspaceDir); const targetDir = resolveUserPath(params.targetWorkspaceDir); + if (sourceDir === targetDir) { return; } diff --git a/src/auto-reply/reply/commands-context-report.ts b/src/auto-reply/reply/commands-context-report.ts index 833964523d0..b6d7bea37fd 100644 --- a/src/auto-reply/reply/commands-context-report.ts +++ b/src/auto-reply/reply/commands-context-report.ts @@ -65,22 +65,23 @@ async function resolveContextReport( sessionKey: params.sessionKey, sessionId: params.sessionEntry?.sessionId, }); + const sandboxRuntime = resolveSandboxRuntimeStatus({ + cfg: params.cfg, + sessionKey: params.ctx.SessionKey ?? params.sessionKey, + }); const skillsSnapshot = (() => { try { return buildWorkspaceSkillSnapshot(workspaceDir, { config: params.cfg, eligibility: { remote: getRemoteSkillEligibility() }, snapshotVersion: getSkillsSnapshotVersion(workspaceDir), + scopeToWorkspace: sandboxRuntime.sandboxed, }); } catch { return { prompt: "", skills: [], resolvedSkills: [] }; } })(); const skillsPrompt = skillsSnapshot.prompt ?? ""; - const sandboxRuntime = resolveSandboxRuntimeStatus({ - cfg: params.cfg, - sessionKey: params.ctx.SessionKey ?? params.sessionKey, - }); const tools = (() => { try { return createOpenClawCodingTools({ diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 3e0e2bb7c8a..46f14b2f4be 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveUserTimezone } from "../../agents/date-time.js"; +import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"; @@ -149,6 +150,10 @@ export async function ensureSkillSnapshot(params: { skillFilter, } = params; + // Sandboxed agents should only see workspace skills — managed/bundled skill + // paths are outside the sandbox root and would be blocked by path assertions. + const sandboxed = sessionKey ? resolveSandboxRuntimeStatus({ cfg, sessionKey }).sandboxed : false; + let nextEntry = sessionEntry; let systemSent = sessionEntry?.systemSent ?? false; const remoteEligibility = getRemoteSkillEligibility(); @@ -170,6 +175,7 @@ export async function ensureSkillSnapshot(params: { skillFilter, eligibility: { remote: remoteEligibility }, snapshotVersion, + scopeToWorkspace: sandboxed, }) : current.skillsSnapshot; nextEntry = { @@ -194,6 +200,7 @@ export async function ensureSkillSnapshot(params: { skillFilter, eligibility: { remote: remoteEligibility }, snapshotVersion, + scopeToWorkspace: sandboxed, }) : (nextEntry?.skillsSnapshot ?? (isFirstTurnInSession @@ -203,6 +210,7 @@ export async function ensureSkillSnapshot(params: { skillFilter, eligibility: { remote: remoteEligibility }, snapshotVersion, + scopeToWorkspace: sandboxed, }))); if ( skillsSnapshot && diff --git a/src/commands/agent.ts b/src/commands/agent.ts index e8de4b4d86d..26526952fd7 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -24,6 +24,7 @@ import { resolveThinkingDefault, } from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { resolveSandboxRuntimeStatus } from "../agents/sandbox/runtime-status.js"; import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { getSkillsSnapshotVersion } from "../agents/skills/refresh.js"; import { resolveAgentTimeoutMs } from "../agents/timeout.js"; @@ -315,12 +316,16 @@ export async function agentCommand( const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot; const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir); const skillFilter = resolveAgentSkillsFilter(cfg, sessionAgentId); + const sandboxed = sessionKey + ? resolveSandboxRuntimeStatus({ cfg, sessionKey }).sandboxed + : false; const skillsSnapshot = needsSkillsSnapshot ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg, eligibility: { remote: getRemoteSkillEligibility() }, snapshotVersion: skillsSnapshotVersion, skillFilter, + scopeToWorkspace: sandboxed, }) : sessionEntry?.skillsSnapshot;