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.
This commit is contained in:
Tarun Sukhani
2026-02-05 18:43:05 +00:00
parent 50f095ecb0
commit bf5a7a05dd
5 changed files with 69 additions and 5 deletions

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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 &&

View File

@@ -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;